openvibers/openvibe.sdk Commits

v0.11.0: openvibe-sdk/chat (global chat, rooms and DMs over REST as the token's person; writes never retried) and openvibe-sdk/ai (runs create/get/list/cancel/retry/citations, waitFor, and the direct ai.<op> operations; a create is retried only with an idempotency key) — WS-F task 4

OP OpenVibers committed 25 Sept 2026 · a4aae65654 · 21 files, +529 −22


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 3713d3e..9cf4697 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,10 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.11.0 (2026-09-25)
++
++Two more clients (roadmap WS-F task 4). **`openvibe-sdk/chat`**: OpenVibe.Chat over REST as the token's person: `global.send/history/search`, `rooms.list/create/get/update/messages/send/deleteMessage/join/leave/read/members/setMember`, `dms.list/create/get/messages/send/deleteMessage/read/unread/blocks/block/unblock`, `exportMine()`; writes are never retried, a room or conversation you may not read is `null`. **`openvibe-sdk/ai`**: OpenVibe.AI's run API: `runs.create(workflow, input, { wait, version, idempotencyKey, target, attribution, onBehalfOf, options })`, `runs.get/list/cancel/retry/citations/addCitations`, `runs.waitFor(id)` (polls until succeeded, failed or cancelled), and the direct operations `chat/generate/summarize/classify/extract/enrich/embed`; a create is retried only with an idempotency key. Both in the browser bundle, ESM and types. Additive.
++
## 0.10.0 (2026-09-25)
**`openvibe-sdk/search`** (roadmap WS-F task 4): OpenVibe.Search's query API. `createSearchClient(client)` gives `query(text, { owner, type, lang, filter, facets, limit, cursor })` (one page: `results`, `next_cursor`, optional facet counts), `iterate(text, opts)` (every result across cursor pages, `max` to stop early), `suggest(text)` and `document(owner, type, id)` (null when missing or not yours to see). Anonymous callers get public documents; a signed-in person also gets restricted documents naming them; a first-party service with `search.query.delegate` passes `actingSubject`. Several values of one facet go as repeated `facet.<key>` parameters. In the browser bundle too. Additive.
ddiff --git a/README.md b/README.md
iindex 69adc06..4c387f8 100644
---- a/README.md
++++ b/README.md
@@@ -165,6 +165,8 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `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/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`, `as(subject)` |
| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) |
++| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()` |
++| `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `chat/generate/summarize/classify/extract/enrich/embed` |
| `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/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()` |
| `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` |
ddiff --git a/browser.js b/browser.js
iindex 71ce807..9c06bb0 100644
---- a/browser.js
++++ b/browser.js
@@@ -14,6 +14,8 @@ module.exports = {
media: require('./src/media'),
community: require('./src/community'),
search: require('./src/search'),
++ chat: require('./src/chat'),
++ ai: require('./src/ai'),
jobs: require('./src/jobs'),
tools: require('./src/tools'),
projects: require('./src/projects'),
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex f915006..2fb47c4 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.10.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.11.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
--// (auth, registry, modules, realtime, media, community, search, jobs, tools, projects). No server-only code: no service tokens, JWT verification, events, identity.
++// (auth, registry, modules, realtime, media, community, search, chat, ai, jobs, tools, projects). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
@@@ -21,9 +21,11 @@ module.exports = {
media: __load(12),
community: __load(14),
search: __load(15),
-- jobs: __load(16),
-- tools: __load(18),
-- projects: __load(19),
++ chat: __load(16),
++ ai: __load(17),
++ jobs: __load(18),
++ tools: __load(20),
++ projects: __load(21),
};
},
// ── 1: src/core/index.js ───────────────────────────────────────────
@@@ -38,7 +40,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.10.0';
++const SDK_VERSION = '0.11.0';
module.exports = {
createClient,
@@@ -1829,7 +1831,160 @@ function createSearchClient(client, defaults = {}) {
module.exports = { createSearchClient, queryOf };
},
--// ── 16: src/jobs.js ─────────────────────────────────────────────────
++// ── 16: src/chat.js ─────────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++/**
++ * openvibe-sdk/chat: OpenVibe.Chat over REST (roadmap WS-F task 4): global chat, rooms and direct
++ * messages, for bots and apps. Browser-safe. Live delivery is Chat's WebSocket (/ws/chat); this is the
++ * REST side of the same data.
++ *
++ * const chat = createChatClient(client); // a signed-in person's token, or an API token
++ * await chat.global.send('hello');
++ * const room = await chat.rooms.create({ name: 'Builders' });
++ * await chat.rooms.send(room.room.slug, 'first!');
++ * const dm = await chat.dms.create([42]);
++ * await chat.dms.send(dm.conversation.id, 'hi');
++ *
++ * Every call acts as the token's person (Chat has no service acting-for-someone mode). Writes are never
++ * retried (Chat does not deduplicate them); reads are. A missing or unreadable room or conversation is
++ * null from get().
++ */
++const { isOpenVibeError } = __load(3);
++
++const enc = encodeURIComponent;
++
++function createChatClient(client, defaults = {}) {
++ const { baseUrl } = defaults;
++ const call = (opts, o = {}) => client.json({ service: 'chat', baseUrl, audience: 'openvibe.chat', ...opts, signal: o.signal });
++ const once = { idempotencyKey: false };
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && (err.status === 404 || err.status === 403)) return null; throw err; });
++ const room = (slug) => `/api/chat/rooms/${enc(slug)}`;
++ const conv = (id) => `/api/dm/conversations/${enc(id)}`;
++
++ return {
++ global: {
++ /** { messages, … } — the global feed; query: limit, after_id (a delta after a message id). */
++ history: (query = {}, o = {}) => call({ path: '/api/chat/global/history', query }, o),
++ /** Post to global chat as the token's person. { message: string, reply_to_id?, auto_delete_minutes? } */
++ send: (message, extra = {}, o = {}) => call({ method: 'POST', path: '/api/chat/send', json: { message, ...extra }, ...once }, o),
++ /** Your own lines (staff with staff.moderation.logs may pass user_id). query: q, user_id, stream_id, limit, offset */
++ search: (query = {}, o = {}) => call({ path: '/api/chat/search', query }, o),
++ },
++ rooms: {
++ list: (o = {}) => call({ path: '/api/chat/rooms' }, o),
++ create: (input, o = {}) => call({ method: 'POST', path: '/api/chat/rooms', json: input, ...once }, o),
++ get: (slug, o = {}) => orNull(call({ path: room(slug) }, o)),
++ update: (slug, patch, o = {}) => call({ method: 'PATCH', path: room(slug), json: patch, ...once }, o),
++ /** query: before | after (message id), limit */
++ messages: (slug, query = {}, o = {}) => call({ path: `${room(slug)}/messages`, query }, o),
++ send: (slug, message, o = {}) => call({ method: 'POST', path: `${room(slug)}/messages`, json: { message }, ...once }, o),
++ deleteMessage: (slug, id, o = {}) => call({ method: 'DELETE', path: `${room(slug)}/messages/${enc(id)}`, ...once }, o),
++ join: (slug, o = {}) => call({ method: 'POST', path: `${room(slug)}/join`, ...once }, o),
++ leave: (slug, o = {}) => call({ method: 'POST', path: `${room(slug)}/leave`, ...once }, o),
++ read: (slug, lastId, o = {}) => call({ method: 'POST', path: `${room(slug)}/read`, json: lastId != null ? { last_id: lastId } : {}, ...once }, o),
++ members: (slug, o = {}) => call({ path: `${room(slug)}/members` }, o),
++ /** Owners and moderators: role member | mod | blocked | none */
++ setMember: (slug, username, role, o = {}) => call({ method: 'POST', path: `${room(slug)}/members`, json: { username, role }, ...once }, o),
++ },
++ dms: {
++ list: (o = {}) => call({ path: '/api/dm/conversations' }, o),
++ /** A 1:1 conversation is reused; more ids make a group ({ name }). */
++ create: (userIds, extra = {}, o = {}) => call({ method: 'POST', path: '/api/dm/conversations', json: { user_ids: userIds, ...extra }, ...once }, o),
++ get: (id, o = {}) => orNull(call({ path: conv(id) }, o)),
++ /** query: limit, before */
++ messages: (id, query = {}, o = {}) => call({ path: `${conv(id)}/messages`, query }, o),
++ send: (id, message, o = {}) => call({ method: 'POST', path: `${conv(id)}/messages`, json: { message }, ...once }, o),
++ deleteMessage: (id, msgId, o = {}) => call({ method: 'DELETE', path: `${conv(id)}/messages/${enc(msgId)}`, ...once }, o),
++ read: (id, o = {}) => call({ method: 'POST', path: `${conv(id)}/read`, ...once }, o),
++ unread: (o = {}) => call({ path: '/api/dm/unread' }, o),
++ blocks: (o = {}) => call({ path: '/api/dm/blocks' }, o),
++ block: (userId, o = {}) => call({ method: 'POST', path: `/api/dm/blocks/${enc(userId)}`, ...once }, o),
++ unblock: (userId, o = {}) => call({ method: 'DELETE', path: `/api/dm/blocks/${enc(userId)}`, ...once }, o),
++ },
++ /** Every message you sent that is still visible: { username, total, truncated, messages } (JSON). */
++ exportMine: (o = {}) => call({ path: '/api/chat/me/export' }, o),
++ };
++}
++
++module.exports = { createChatClient };
++},
++// ── 17: src/ai.js ───────────────────────────────────────────────────
++function (module, exports) {
++'use strict';
++/**
++ * openvibe-sdk/ai: OpenVibe.AI's run API (roadmap WS-F task 4). For first-party services (a Network
++ * service token with ai.run.create / ai.run.read, audience openvibe.ai) and developer apps granted
++ * them; workflows are namespaced by product (a caller may only run the namespaces it was granted).
++ *
++ * const ai = createAiClient(client);
++ * const { run } = await ai.runs.create('live.stream.describe_frame', { image_url }, { wait: 20000 });
++ * const done = run.status === 'succeeded' ? run : await ai.runs.waitFor(run.id);
++ * const summary = await ai.summarize({ text }, { wait: 30000 }); // ai.summarize, waited for
++ *
++ * create() answers { run } with 201 (finished within the wait, or served from cache), 202 (still queued
++ * or running: poll get() or use waitFor()), or 200 (an idempotent replay: `replayed: true`). Pass an
++ * idempotencyKey so a retried create never runs twice; creates are otherwise not retried. A quota
++ * refusal or a full queue is a 429 error carrying Retry-After.
++ */
++const { isOpenVibeError } = __load(3);
++
++const enc = encodeURIComponent;
++const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
++const OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed'];
++
++function createAiClient(client, defaults = {}) {
++ const { baseUrl } = defaults;
++ const call = (opts, o = {}) => client.json({ service: 'ai', baseUrl, audience: 'openvibe.ai', ...opts, signal: o.signal });
++ const writeOpts = (o) => (o.idempotencyKey ? { idempotencyKey: o.idempotencyKey } : { idempotencyKey: false });
++ const waitQuery = (o) => (o.wait != null ? { wait: Math.max(0, Math.floor(o.wait)) } : undefined);
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++
++ const runs = {
++ /** workflow key (e.g. 'wiki.generate_page'), its input; o: { wait (ms), version, idempotencyKey, target, attribution, onBehalfOf, options } */
++ create(workflow, input = {}, o = {}) {
++ const body = { workflow, input };
++ if (o.version != null) body.version = o.version;
++ if (o.target !== undefined) body.target = o.target;
++ if (o.attribution !== undefined) body.attribution = o.attribution;
++ if (o.onBehalfOf !== undefined) body.on_behalf_of = o.onBehalfOf;
++ if (o.options !== undefined) body.options = o.options;
++ return call({ method: 'POST', path: '/api/v1/runs', query: waitQuery(o), json: body, ...writeOpts(o) }, o);
++ },
++ list: (query = {}, o = {}) => call({ path: '/api/v1/runs', query }, o),
++ get: (id, o = {}) => orNull(call({ path: `/api/v1/runs/${enc(id)}` }, o)),
++ cancel: (id, o = {}) => call({ method: 'POST', path: `/api/v1/runs/${enc(id)}/cancel`, idempotencyKey: false }, o),
++ retry: (id, o = {}) => call({ method: 'POST', path: `/api/v1/runs/${enc(id)}/retry`, query: waitQuery(o), ...writeOpts(o) }, o),
++ citations: (id, o = {}) => call({ path: `/api/v1/runs/${enc(id)}/citations` }, o),
++ addCitations: (id, citations, o = {}) => call({ method: 'POST', path: `/api/v1/runs/${enc(id)}/citations`, json: { citations }, idempotencyKey: false }, o),
++ /** Poll get() until the run finishes (succeeded, failed or cancelled) or `timeoutMs` passes. → the run */
++ async waitFor(id, { intervalMs = 1000, timeoutMs = 120000, signal } = {}) {
++ const until = Date.now() + timeoutMs;
++ for (;;) {
++ const got = await runs.get(id, { signal });
++ const run = got && (got.run || got);
++ if (!run) throw new Error(`run ${id} not found`);
++ if (TERMINAL.has(run.status)) return run;
++ if (Date.now() + intervalMs > until) throw new Error(`run ${id} still ${run.status} after ${timeoutMs} ms`);
++ await new Promise((r) => setTimeout(r, intervalMs));
++ if (signal && signal.aborted) throw new Error('aborted');
++ }
++ },
++ };
++
++ const api = { runs, TERMINAL };
++ // Direct operations: a run of workflow ai.<op> with the input as the body, waited for by AI.
++ for (const op of OPS) {
++ api[op] = (input = {}, o = {}) => call({ method: 'POST', path: `/api/v1/${op}`, query: waitQuery(o),
++ json: { ...input, ...(o.target !== undefined ? { target: o.target } : {}), ...(o.attribution !== undefined ? { attribution: o.attribution } : {}), ...(o.options !== undefined ? { options: o.options } : {}) },
++ ...writeOpts(o) }, o);
++ }
++ return api;
++}
++
++module.exports = { createAiClient, TERMINAL, OPS };
++},
++// ── 18: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -1867,7 +2022,7 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { appendFiles } = __load(17);
++const { appendFiles } = __load(19);
const { parseSSE } = __load(11);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
@@@ -2031,7 +2186,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
--// ── 17: src/core/form.js ────────────────────────────────────────────
++// ── 19: src/core/form.js ────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2065,7 +2220,7 @@ function appendFiles(form, list, where) {
module.exports = { isBlob, isUpload, appendFiles };
},
--// ── 18: src/tools.js ────────────────────────────────────────────────
++// ── 20: src/tools.js ────────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2099,8 +2254,8 @@ function (module, exports) {
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
--const { isUpload, appendFiles } = __load(17);
--const { createJobsClient } = __load(16);
++const { isUpload, appendFiles } = __load(19);
++const { createJobsClient } = __load(18);
const enc = encodeURIComponent;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
@@@ -2334,7 +2489,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
module.exports = { createToolsClient, ToolRunError, isToolRunError };
},
--// ── 19: src/projects.js ─────────────────────────────────────────────
++// ── 21: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
'use strict';
/**
@@@ -2466,6 +2621,6 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, modules, realtime, media, community, search,
-- jobs, tools, projects,
++ chat, ai, jobs, tools, projects,
} = sdk;
export default sdk;
ddiff --git a/esm/ai.mjs b/esm/ai.mjs
nnew file mode 100644
iindex 0000000..b13fb68
---- /dev/null
++++ b/esm/ai.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/ai.js. Do not edit.
++import cjs from '../src/ai.js';
++
++export const {
++ createAiClient, TERMINAL, OPS,
++} = cjs;
++export default cjs;
ddiff --git a/esm/chat.mjs b/esm/chat.mjs
nnew file mode 100644
iindex 0000000..7e0dfc6
---- /dev/null
++++ b/esm/chat.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/chat.js. Do not edit.
++import cjs from '../src/chat.js';
++
++export const {
++ createChatClient,
++} = cjs;
++export default cjs;
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex d9a1328..ed95383 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -6,6 +6,7 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
-- community, search, jobs, tools, projects, vip,
++ community, search, chat, ai, jobs, tools,
++ projects, vip,
} = cjs;
export default cjs;
ddiff --git a/index.js b/index.js
iindex ddba334..4882e6d 100644
---- a/index.js
++++ b/index.js
@@@ -17,6 +17,8 @@ module.exports = {
media: require('./src/media'),
community: require('./src/community'),
search: require('./src/search'),
++ chat: require('./src/chat'),
++ ai: require('./src/ai'),
jobs: require('./src/jobs'),
tools: require('./src/tools'),
projects: require('./src/projects'),
ddiff --git a/package-lock.json b/package-lock.json
iindex 6c8b7fd..9370a1c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.10.0",
++ "version": "0.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.10.0",
++ "version": "0.11.0",
"license": "MIT",
"devDependencies": {
"better-sqlite3": "^11.10.0",
ddiff --git a/package.json b/package.json
iindex eb3d490..b0a9833 100644
---- a/package.json
++++ b/package.json
@@@ -1,7 +1,7 @@
{
"name": "openvibe-sdk",
-- "version": "0.10.0",
-- "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, search, Tools (registry, run API and jobs), VIP memberships and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
++ "version": "0.11.0",
++ "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
"browser": "browser.js",
@@@ -70,6 +70,16 @@
"import": "./esm/search.mjs",
"default": "./src/search.js"
},
++ "./chat": {
++ "types": "./types/chat.d.ts",
++ "import": "./esm/chat.mjs",
++ "default": "./src/chat.js"
++ },
++ "./ai": {
++ "types": "./types/ai.d.ts",
++ "import": "./esm/ai.mjs",
++ "default": "./src/ai.js"
++ },
"./jobs": {
"types": "./types/jobs.d.ts",
"import": "./esm/jobs.mjs",
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex f34366d..766a3c7 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -24,6 +24,8 @@ const ENTRIES = {
'media.mjs': '../src/media.js',
'community.mjs': '../src/community.js',
'search.mjs': '../src/search.js',
++ 'chat.mjs': '../src/chat.js',
++ 'ai.mjs': '../src/ai.js',
'jobs.mjs': '../src/jobs.js',
'tools.mjs': '../src/tools.js',
'projects.mjs': '../src/projects.js',
ddiff --git a/src/ai.js b/src/ai.js
nnew file mode 100644
iindex 0000000..b2d3775
---- /dev/null
++++ b/src/ai.js
@@@ -0,0 +1,72 @@
++'use strict';
++/**
++ * openvibe-sdk/ai: OpenVibe.AI's run API (roadmap WS-F task 4). For first-party services (a Network
++ * service token with ai.run.create / ai.run.read, audience openvibe.ai) and developer apps granted
++ * them; workflows are namespaced by product (a caller may only run the namespaces it was granted).
++ *
++ * const ai = createAiClient(client);
++ * const { run } = await ai.runs.create('live.stream.describe_frame', { image_url }, { wait: 20000 });
++ * const done = run.status === 'succeeded' ? run : await ai.runs.waitFor(run.id);
++ * const summary = await ai.summarize({ text }, { wait: 30000 }); // ai.summarize, waited for
++ *
++ * create() answers { run } with 201 (finished within the wait, or served from cache), 202 (still queued
++ * or running: poll get() or use waitFor()), or 200 (an idempotent replay: `replayed: true`). Pass an
++ * idempotencyKey so a retried create never runs twice; creates are otherwise not retried. A quota
++ * refusal or a full queue is a 429 error carrying Retry-After.
++ */
++const { isOpenVibeError } = require('./core/errors');
++
++const enc = encodeURIComponent;
++const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
++const OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed'];
++
++function createAiClient(client, defaults = {}) {
++ const { baseUrl } = defaults;
++ const call = (opts, o = {}) => client.json({ service: 'ai', baseUrl, audience: 'openvibe.ai', ...opts, signal: o.signal });
++ const writeOpts = (o) => (o.idempotencyKey ? { idempotencyKey: o.idempotencyKey } : { idempotencyKey: false });
++ const waitQuery = (o) => (o.wait != null ? { wait: Math.max(0, Math.floor(o.wait)) } : undefined);
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++
++ const runs = {
++ /** workflow key (e.g. 'wiki.generate_page'), its input; o: { wait (ms), version, idempotencyKey, target, attribution, onBehalfOf, options } */
++ create(workflow, input = {}, o = {}) {
++ const body = { workflow, input };
++ if (o.version != null) body.version = o.version;
++ if (o.target !== undefined) body.target = o.target;
++ if (o.attribution !== undefined) body.attribution = o.attribution;
++ if (o.onBehalfOf !== undefined) body.on_behalf_of = o.onBehalfOf;
++ if (o.options !== undefined) body.options = o.options;
++ return call({ method: 'POST', path: '/api/v1/runs', query: waitQuery(o), json: body, ...writeOpts(o) }, o);
++ },
++ list: (query = {}, o = {}) => call({ path: '/api/v1/runs', query }, o),
++ get: (id, o = {}) => orNull(call({ path: `/api/v1/runs/${enc(id)}` }, o)),
++ cancel: (id, o = {}) => call({ method: 'POST', path: `/api/v1/runs/${enc(id)}/cancel`, idempotencyKey: false }, o),
++ retry: (id, o = {}) => call({ method: 'POST', path: `/api/v1/runs/${enc(id)}/retry`, query: waitQuery(o), ...writeOpts(o) }, o),
++ citations: (id, o = {}) => call({ path: `/api/v1/runs/${enc(id)}/citations` }, o),
++ addCitations: (id, citations, o = {}) => call({ method: 'POST', path: `/api/v1/runs/${enc(id)}/citations`, json: { citations }, idempotencyKey: false }, o),
++ /** Poll get() until the run finishes (succeeded, failed or cancelled) or `timeoutMs` passes. → the run */
++ async waitFor(id, { intervalMs = 1000, timeoutMs = 120000, signal } = {}) {
++ const until = Date.now() + timeoutMs;
++ for (;;) {
++ const got = await runs.get(id, { signal });
++ const run = got && (got.run || got);
++ if (!run) throw new Error(`run ${id} not found`);
++ if (TERMINAL.has(run.status)) return run;
++ if (Date.now() + intervalMs > until) throw new Error(`run ${id} still ${run.status} after ${timeoutMs} ms`);
++ await new Promise((r) => setTimeout(r, intervalMs));
++ if (signal && signal.aborted) throw new Error('aborted');
++ }
++ },
++ };
++
++ const api = { runs, TERMINAL };
++ // Direct operations: a run of workflow ai.<op> with the input as the body, waited for by AI.
++ for (const op of OPS) {
++ api[op] = (input = {}, o = {}) => call({ method: 'POST', path: `/api/v1/${op}`, query: waitQuery(o),
++ json: { ...input, ...(o.target !== undefined ? { target: o.target } : {}), ...(o.attribution !== undefined ? { attribution: o.attribution } : {}), ...(o.options !== undefined ? { options: o.options } : {}) },
++ ...writeOpts(o) }, o);
++ }
++ return api;
++}
++
++module.exports = { createAiClient, TERMINAL, OPS };
ddiff --git a/src/chat.js b/src/chat.js
nnew file mode 100644
iindex 0000000..284d815
---- /dev/null
++++ b/src/chat.js
@@@ -0,0 +1,75 @@
++'use strict';
++/**
++ * openvibe-sdk/chat: OpenVibe.Chat over REST (roadmap WS-F task 4): global chat, rooms and direct
++ * messages, for bots and apps. Browser-safe. Live delivery is Chat's WebSocket (/ws/chat); this is the
++ * REST side of the same data.
++ *
++ * const chat = createChatClient(client); // a signed-in person's token, or an API token
++ * await chat.global.send('hello');
++ * const room = await chat.rooms.create({ name: 'Builders' });
++ * await chat.rooms.send(room.room.slug, 'first!');
++ * const dm = await chat.dms.create([42]);
++ * await chat.dms.send(dm.conversation.id, 'hi');
++ *
++ * Every call acts as the token's person (Chat has no service acting-for-someone mode). Writes are never
++ * retried (Chat does not deduplicate them); reads are. A missing or unreadable room or conversation is
++ * null from get().
++ */
++const { isOpenVibeError } = require('./core/errors');
++
++const enc = encodeURIComponent;
++
++function createChatClient(client, defaults = {}) {
++ const { baseUrl } = defaults;
++ const call = (opts, o = {}) => client.json({ service: 'chat', baseUrl, audience: 'openvibe.chat', ...opts, signal: o.signal });
++ const once = { idempotencyKey: false };
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && (err.status === 404 || err.status === 403)) return null; throw err; });
++ const room = (slug) => `/api/chat/rooms/${enc(slug)}`;
++ const conv = (id) => `/api/dm/conversations/${enc(id)}`;
++
++ return {
++ global: {
++ /** { messages, … } — the global feed; query: limit, after_id (a delta after a message id). */
++ history: (query = {}, o = {}) => call({ path: '/api/chat/global/history', query }, o),
++ /** Post to global chat as the token's person. { message: string, reply_to_id?, auto_delete_minutes? } */
++ send: (message, extra = {}, o = {}) => call({ method: 'POST', path: '/api/chat/send', json: { message, ...extra }, ...once }, o),
++ /** Your own lines (staff with staff.moderation.logs may pass user_id). query: q, user_id, stream_id, limit, offset */
++ search: (query = {}, o = {}) => call({ path: '/api/chat/search', query }, o),
++ },
++ rooms: {
++ list: (o = {}) => call({ path: '/api/chat/rooms' }, o),
++ create: (input, o = {}) => call({ method: 'POST', path: '/api/chat/rooms', json: input, ...once }, o),
++ get: (slug, o = {}) => orNull(call({ path: room(slug) }, o)),
++ update: (slug, patch, o = {}) => call({ method: 'PATCH', path: room(slug), json: patch, ...once }, o),
++ /** query: before | after (message id), limit */
++ messages: (slug, query = {}, o = {}) => call({ path: `${room(slug)}/messages`, query }, o),
++ send: (slug, message, o = {}) => call({ method: 'POST', path: `${room(slug)}/messages`, json: { message }, ...once }, o),
++ deleteMessage: (slug, id, o = {}) => call({ method: 'DELETE', path: `${room(slug)}/messages/${enc(id)}`, ...once }, o),
++ join: (slug, o = {}) => call({ method: 'POST', path: `${room(slug)}/join`, ...once }, o),
++ leave: (slug, o = {}) => call({ method: 'POST', path: `${room(slug)}/leave`, ...once }, o),
++ read: (slug, lastId, o = {}) => call({ method: 'POST', path: `${room(slug)}/read`, json: lastId != null ? { last_id: lastId } : {}, ...once }, o),
++ members: (slug, o = {}) => call({ path: `${room(slug)}/members` }, o),
++ /** Owners and moderators: role member | mod | blocked | none */
++ setMember: (slug, username, role, o = {}) => call({ method: 'POST', path: `${room(slug)}/members`, json: { username, role }, ...once }, o),
++ },
++ dms: {
++ list: (o = {}) => call({ path: '/api/dm/conversations' }, o),
++ /** A 1:1 conversation is reused; more ids make a group ({ name }). */
++ create: (userIds, extra = {}, o = {}) => call({ method: 'POST', path: '/api/dm/conversations', json: { user_ids: userIds, ...extra }, ...once }, o),
++ get: (id, o = {}) => orNull(call({ path: conv(id) }, o)),
++ /** query: limit, before */
++ messages: (id, query = {}, o = {}) => call({ path: `${conv(id)}/messages`, query }, o),
++ send: (id, message, o = {}) => call({ method: 'POST', path: `${conv(id)}/messages`, json: { message }, ...once }, o),
++ deleteMessage: (id, msgId, o = {}) => call({ method: 'DELETE', path: `${conv(id)}/messages/${enc(msgId)}`, ...once }, o),
++ read: (id, o = {}) => call({ method: 'POST', path: `${conv(id)}/read`, ...once }, o),
++ unread: (o = {}) => call({ path: '/api/dm/unread' }, o),
++ blocks: (o = {}) => call({ path: '/api/dm/blocks' }, o),
++ block: (userId, o = {}) => call({ method: 'POST', path: `/api/dm/blocks/${enc(userId)}`, ...once }, o),
++ unblock: (userId, o = {}) => call({ method: 'DELETE', path: `/api/dm/blocks/${enc(userId)}`, ...once }, o),
++ },
++ /** Every message you sent that is still visible: { username, total, truncated, messages } (JSON). */
++ exportMine: (o = {}) => call({ path: '/api/chat/me/export' }, o),
++ };
++}
++
++module.exports = { createChatClient };
ddiff --git a/src/core/index.js b/src/core/index.js
iindex a06e7c9..f068bd7 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.10.0';
++const SDK_VERSION = '0.11.0';
module.exports = {
createClient,
ddiff --git a/test/ai.test.js b/test/ai.test.js
nnew file mode 100644
iindex 0000000..8278c12
---- /dev/null
++++ b/test/ai.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++/** AI: create with wait and an idempotency key, no retried create without one, waitFor polls to the end, direct ops. */
++const assert = require('node:assert/strict');
++const { stubServer, send, run } = require('./helpers');
++const { createClient } = require('../src/core');
++const { createAiClient } = require('../src/ai');
++
++async function aiStub() {
++ let polls = 0, busy = 0;
++ return stubServer((req, res, body) => {
++ const u = new URL(req.url, 'http://x');
++ if (req.method === 'POST' && u.pathname === '/api/v1/runs') {
++ const b = JSON.parse(String(body));
++ if (b.workflow === 'busy.flow' && busy++ === 0) return send(res, 503, { code: 'ai.busy' });
++ return send(res, 202, { run: { id: 'run_1', status: 'queued' } });
++ }
++ if (u.pathname === '/api/v1/runs/run_1') return send(res, 200, { run: { id: 'run_1', status: ++polls >= 3 ? 'succeeded' : 'running', output: polls >= 3 ? { ok: true } : undefined } });
++ if (u.pathname === '/api/v1/runs/missing') return send(res, 404, { code: 'run.not_found' });
++ if (u.pathname === '/api/v1/summarize') return send(res, 201, { run: { id: 'run_2', status: 'succeeded' } });
++ return send(res, 404, {});
++ });
++}
++
++run([
++ ['create sends workflow, input, wait and the idempotency key; waitFor polls until finished', async () => {
++ const srv = await aiStub();
++ const ai = createAiClient(createClient({ baseUrls: { ai: srv.url }, getToken: ({ audience }) => `svc-${audience}` }));
++ const r = await ai.runs.create('wiki.generate_page', { title: 'X' }, { wait: 2000, idempotencyKey: 'k-1', onBehalfOf: { type: 'user', id: 'usr_x' } });
++ assert.equal(r.run.status, 'queued');
++ const req = srv.requests[0];
++ assert.equal(req.url, '/api/v1/runs?wait=2000');
++ assert.deepEqual(JSON.parse(String(req.body)), { workflow: 'wiki.generate_page', input: { title: 'X' }, on_behalf_of: { type: 'user', id: 'usr_x' } });
++ assert.equal(req.headers.authorization, 'Bearer svc-openvibe.ai');
++ assert.equal(req.headers['idempotency-key'], 'k-1');
++ const done = await ai.runs.waitFor('run_1', { intervalMs: 5 });
++ assert.equal(done.status, 'succeeded');
++ assert.equal(await ai.runs.get('missing'), null);
++ await srv.close();
++ }],
++ ['a create without a key is not retried; direct ops post the input as the body', async () => {
++ const srv = await aiStub();
++ const ai = createAiClient(createClient({ baseUrls: { ai: srv.url }, token: 't', retries: 2 }));
++ await assert.rejects(ai.runs.create('busy.flow', {}), (e) => e.status === 503);
++ assert.equal(srv.requests.length, 1, 'one attempt');
++ const s = await ai.summarize({ text: 'long text' }, { wait: 30000 });
++ assert.equal(s.run.id, 'run_2');
++ assert.equal(srv.requests[1].url, '/api/v1/summarize?wait=30000');
++ assert.deepEqual(JSON.parse(String(srv.requests[1].body)), { text: 'long text' });
++ await srv.close();
++ }],
++]);
ddiff --git a/test/chat.test.js b/test/chat.test.js
nnew file mode 100644
iindex 0000000..24856b9
---- /dev/null
++++ b/test/chat.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++/** Chat: paths, bodies and methods for global, rooms and DMs; writes never retried; a missing room is null. */
++const assert = require('node:assert/strict');
++const { stubServer, send, run } = require('./helpers');
++const { createClient } = require('../src/core');
++const { createChatClient } = require('../src/chat');
++
++async function chatStub() {
++ let busy = 0;
++ return stubServer((req, res) => {
++ if (req.url.startsWith('/api/chat/rooms/private-one')) return send(res, 404, { error: 'Room not found' });
++ if (req.url === '/api/chat/send' && busy++ === 0) return send(res, 503, { error: 'busy' });
++ return send(res, 200, { ok: true, url: req.url });
++ });
++}
++
++run([
++ ['global, rooms and DMs go to the right paths with the right bodies', async () => {
++ const srv = await chatStub();
++ const chat = createChatClient(createClient({ baseUrls: { chat: srv.url }, token: 'user-jwt', retries: 2 }));
++ await chat.rooms.create({ name: 'Builders' });
++ await chat.rooms.send('builders', 'first!');
++ await chat.rooms.read('builders', 12);
++ await chat.rooms.setMember('builders', 'bob', 'mod');
++ await chat.dms.create([42], { name: 'pair' });
++ await chat.dms.send(7, 'hi');
++ await chat.dms.messages(7, { limit: 20 });
++ await chat.global.history({ after_id: 5 });
++ const got = srv.requests.map((r) => [r.method, r.url, r.body && String(r.body).length ? JSON.parse(String(r.body)) : null]);
++ assert.deepEqual(got, [
++ ['POST', '/api/chat/rooms', { name: 'Builders' }],
++ ['POST', '/api/chat/rooms/builders/messages', { message: 'first!' }],
++ ['POST', '/api/chat/rooms/builders/read', { last_id: 12 }],
++ ['POST', '/api/chat/rooms/builders/members', { username: 'bob', role: 'mod' }],
++ ['POST', '/api/dm/conversations', { user_ids: [42], name: 'pair' }],
++ ['POST', '/api/dm/conversations/7/messages', { message: 'hi' }],
++ ['GET', '/api/dm/conversations/7/messages?limit=20', null],
++ ['GET', '/api/chat/global/history?after_id=5', null],
++ ]);
++ assert.equal(srv.requests[0].headers.authorization, 'Bearer user-jwt');
++ await srv.close();
++ }],
++ ['a failed send is not retried; a room you may not read is null', async () => {
++ const srv = await chatStub();
++ const chat = createChatClient(createClient({ baseUrls: { chat: srv.url }, token: 't', retries: 2 }));
++ await assert.rejects(chat.global.send('hello'), (e) => e.status === 503);
++ assert.equal(srv.requests.filter((r) => r.url === '/api/chat/send').length, 1, 'one attempt only');
++ assert.equal(await chat.rooms.get('private-one'), null);
++ await srv.close();
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex e1db771..9055df6 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 18);
++ assert.equal(subpaths.length, 20);
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/types/ai.d.ts b/types/ai.d.ts
nnew file mode 100644
iindex 0000000..5f11198
---- /dev/null
++++ b/types/ai.d.ts
@@@ -0,0 +1,20 @@
++import type { OpenVibeClient } from './core';
++
++export interface AiRun { id: string; status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | string; workflow?: { key: string; version: number }; output?: unknown; error?: unknown; [field: string]: unknown; }
++export interface RunOptions { wait?: number; version?: number; idempotencyKey?: string; target?: unknown; attribution?: unknown; onBehalfOf?: unknown; options?: Record<string, unknown>; signal?: AbortSignal; }
++export interface RunResponse { run: AiRun; replayed?: boolean }
++export interface AiRuns {
++ create(workflow: string, input?: Record<string, unknown>, opts?: RunOptions): Promise<RunResponse>;
++ list(query?: Record<string, string | number>, opts?: { signal?: AbortSignal }): Promise<{ runs: AiRun[]; [field: string]: unknown }>;
++ get(id: string, opts?: { signal?: AbortSignal }): Promise<RunResponse | null>;
++ cancel(id: string, opts?: { signal?: AbortSignal }): Promise<RunResponse>;
++ retry(id: string, opts?: Pick<RunOptions, 'wait' | 'idempotencyKey' | 'signal'>): Promise<RunResponse>;
++ citations(id: string, opts?: { signal?: AbortSignal }): Promise<{ citations: Array<Record<string, unknown>> }>;
++ addCitations(id: string, citations: Array<Record<string, unknown>>, opts?: { signal?: AbortSignal }): Promise<{ citations: Array<Record<string, unknown>> }>;
++ waitFor(id: string, opts?: { intervalMs?: number; timeoutMs?: number; signal?: AbortSignal }): Promise<AiRun>;
++}
++type Op = (input?: Record<string, unknown>, opts?: Omit<RunOptions, 'version' | 'onBehalfOf'>) => Promise<RunResponse>;
++export interface AiClient { runs: AiRuns; TERMINAL: Set<string>; chat: Op; generate: Op; summarize: Op; classify: Op; extract: Op; enrich: Op; embed: Op; }
++export function createAiClient(client: OpenVibeClient, defaults?: { baseUrl?: string }): AiClient;
++export const TERMINAL: Set<string>;
++export const OPS: string[];
ddiff --git a/types/bundle.d.ts b/types/bundle.d.ts
iindex 62d72c1..8e1bf09 100644
---- a/types/bundle.d.ts
++++ b/types/bundle.d.ts
@@@ -6,6 +6,8 @@ import * as realtime from './realtime';
import * as media from './media';
import * as community from './community';
import * as search from './search';
++import * as chat from './chat';
++import * as ai from './ai';
import * as jobs from './jobs';
import * as tools from './tools';
import * as projects from './projects';
@@@ -19,12 +21,14 @@ export * as realtime from './realtime';
export * as media from './media';
export * as community from './community';
export * as search from './search';
++export * as chat from './chat';
++export * as ai from './ai';
export * as jobs from './jobs';
export * as tools from './tools';
export * as projects from './projects';
declare const sdk: typeof core & {
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
-- media: typeof media; community: typeof community; search: typeof search; jobs: typeof jobs; tools: typeof tools; projects: typeof projects;
++ media: typeof media; community: typeof community; search: typeof search; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects;
};
export default sdk;
ddiff --git a/types/chat.d.ts b/types/chat.d.ts
nnew file mode 100644
iindex 0000000..e81ec1f
---- /dev/null
++++ b/types/chat.d.ts
@@@ -0,0 +1,40 @@
++import type { OpenVibeClient } from './core';
++
++type Opts = { signal?: AbortSignal };
++type Json = Record<string, unknown>;
++export interface ChatClient {
++ global: {
++ history(query?: { limit?: number; after_id?: number }, opts?: Opts): Promise<Json>;
++ send(message: string, extra?: { reply_to_id?: number; auto_delete_minutes?: number }, opts?: Opts): Promise<Json>;
++ search(query?: { q?: string; user_id?: number; stream_id?: number; limit?: number; offset?: number }, opts?: Opts): Promise<{ messages: Json[]; total?: number }>;
++ };
++ rooms: {
++ list(opts?: Opts): Promise<Json>;
++ create(input: { name: string; slug?: string; topic?: string; visibility?: 'public' | 'private' }, opts?: Opts): Promise<Json>;
++ get(slug: string, opts?: Opts): Promise<Json | null>;
++ update(slug: string, patch: { name?: string; topic?: string; visibility?: 'public' | 'private'; slow_seconds?: number }, opts?: Opts): Promise<Json>;
++ messages(slug: string, query?: { before?: number; after?: number; limit?: number }, opts?: Opts): Promise<Json>;
++ send(slug: string, message: string, opts?: Opts): Promise<Json>;
++ deleteMessage(slug: string, id: number | string, opts?: Opts): Promise<Json>;
++ join(slug: string, opts?: Opts): Promise<Json>;
++ leave(slug: string, opts?: Opts): Promise<Json>;
++ read(slug: string, lastId?: number, opts?: Opts): Promise<Json>;
++ members(slug: string, opts?: Opts): Promise<Json>;
++ setMember(slug: string, username: string, role: 'member' | 'mod' | 'blocked' | 'none', opts?: Opts): Promise<Json>;
++ };
++ dms: {
++ list(opts?: Opts): Promise<Json>;
++ create(userIds: number[], extra?: { name?: string }, opts?: Opts): Promise<Json>;
++ get(id: number | string, opts?: Opts): Promise<Json | null>;
++ messages(id: number | string, query?: { limit?: number; before?: number }, opts?: Opts): Promise<Json>;
++ send(id: number | string, message: string, opts?: Opts): Promise<Json>;
++ deleteMessage(id: number | string, msgId: number | string, opts?: Opts): Promise<Json>;
++ read(id: number | string, opts?: Opts): Promise<Json>;
++ unread(opts?: Opts): Promise<{ unread: number }>;
++ blocks(opts?: Opts): Promise<Json>;
++ block(userId: number | string, opts?: Opts): Promise<Json>;
++ unblock(userId: number | string, opts?: Opts): Promise<Json>;
++ };
++ exportMine(opts?: Opts): Promise<{ username: string; exported_at: string; total: number; truncated: boolean; messages: Json[] }>;
++}
++export function createChatClient(client: OpenVibeClient, defaults?: { baseUrl?: string }): ChatClient;
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex 9adcc39..cbee55e 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -8,6 +8,8 @@ export * as realtime from './realtime';
export * as media from './media';
export * as community from './community';
export * as search from './search';
++export * as chat from './chat';
++export * as ai from './ai';
export * as jobs from './jobs';
export * as tools from './tools';
export * as projects from './projects';