openvibers/openvibe.sdk Commits

openvibe-sdk 0.94.0: schedules, steps, digest, polls, controls and reports (S7) (#128)

OP OpenVibers committed 6 days ago · cf90dbbd41 · 21 files, +776 −24

* openvibe-sdk 0.94.0: schedules, steps, digest, polls, controls and reports (S7)

* openvibe-sdk 0.94.0: the version, the README, STATUS and the bundle

* openvibe-sdk tests: reports is a server-only entry

* openvibe-sdk: the S7 example streams a stub agent's steps end to end

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 425a7f3..20b6b4d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,18 @@
# Changelog
++## 0.94.0 (2026-11-07)
++
++- `openvibe-sdk/streams` gains S7: `schedule()`, `listSchedules()`, `cancelSchedule()` and `remindSchedule()` for
++ scheduled public agent streams; `steps.post()` and `postStep()` for any harness; `digest.read()` and `poll.open()` /
++ `poll.result()` for a Co-pilot's service token; and `onControl()` to honour pause, curtain, mute and end by polling
++ the stream's control log. The guards run before anything leaves the process (an `scd_…` id, a step's title and
++ status, two to four poll options, a 10–300 second poll).
++- Add `openvibe-sdk/reports`: `write()` posts a session report with the person's own token (private by default,
++ idempotent per source and task ref) and `read()` gets one the caller may see. The Report service redacts every text
++ field; the client never sends a raw transcript.
++- Add `examples/cli-agent-stream.js`: about 40 lines that start a stream, spawn any CLI command, post its steps and end
++ the stream with the command's result, using the same ticket path a Node desk uses.
++
## 0.93.0 (2026-11-06)
- `openvibe-sdk/exchange`: the seller side gains the **endpoint form**, so any OpenAI-compatible server can sell, not
ddiff --git a/README.md b/README.md
iindex ce6a1f6..468d78f 100644
---- a/README.md
++++ b/README.md
@@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.93.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.94.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
--After v0.93.0 is tagged:
++After v0.94.0 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.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).
ddiff --git a/STATUS.json b/STATUS.json
iindex 5a44a9f..56f45a8 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,13 +3,15 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.93.0",
-- "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
++ "version": "0.94.0",
++ "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), \u00a73.2; roadmap \u00a730",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
-- "contracts": "openvibe-contracts v0.298.0",
++ "contracts": "openvibe-contracts v0.305.0",
"features": [
-- "chat-apps (openvibe-sdk/chat-apps): the three chat apps (Telegram, Discord, Slack) in one place — the delivery checks (Telegram's secret token, Slack's v0 signing, Discord's Ed25519), the button callback (signed once, used once, expiring), each app's payload as the same shape and splitText() for a long answer",
++ "streams (openvibe-sdk/streams, S7): schedule(), listSchedules(), cancelSchedule() and remindSchedule() for scheduled public agent streams; postStep() and steps.post() from any harness; digest.read() and poll.open()/poll.result() for a Co-pilot service token; onControl() for pause, curtain, mute and end",
++ "reports (openvibe-sdk/reports, S7): write() a session report with the person's own token, private by default and idempotent per source and task ref, and read() one the caller may see; examples/cli-agent-stream.js streams any CLI command's run with steps",
++ "chat-apps (openvibe-sdk/chat-apps): the three chat apps (Telegram, Discord, Slack) in one place \u2014 the delivery checks (Telegram's secret token, Slack's v0 signing, Discord's Ed25519), the button callback (signed once, used once, expiring), each app's payload as the same shape and splitText() for a long answer",
"exchange (openvibe-sdk/exchange): the buyer side (chat, models, offers, presets, usage, receipts) and the seller side (offer, endpointOffers, removeEndpointOffer, earnings, verification) of OpenVibe.Exchange, with the platform webhook signature check; an offer is a Node machine's Network report or a standalone OpenAI-compatible endpoint registered on the public seller API",
"locker (openvibe-sdk/locker, openvibe-sdk/locker/server): the Locker envelope (locker.envelope@1) for end-to-end encryption, WebCrypto only, in Node and the browser: seal/open for text and bytes, sealStream/openStream, the three locks (people, password, link), person key pairs (ECDH P-256, ECDSA P-256), the eight-word fingerprint, wrapFor and rewrap, recoveryWords for the phrase to write down, opening a people lock by key, inspect for what an envelope says about itself, and the header checks a service runs without a key",
"the 1.0 core surface is frozen now, before 1.0 (core, auth, events, db, media): docs/one-point-oh.json gives every export its tier, test/surface.test.js pins the exact names, and removing or renaming one needs a major release; createCodeVerifier and pkceChallenge left in 0.86.0",
@@@ -33,7 +35,7 @@
"valkey, cache, queue, pubsub and a shared limits store on Valkey 9, each with an in-process twin for tests",
"commerce (openvibe-sdk/commerce): the shared Billing client for Tips and VIP, with intentKey and receipt",
"events: the PostgreSQL outbox (lease-claimed, several relays per table) and inbox (handler in its transaction)",
-- "usage: createUsageReporter (openvibe-sdk/usage), the shared platform.usage-sample@1 reporter — validated readings queued idempotency-keyed in an outbox and relayed to Billing with retry, never dropped, refused readings kept",
++ "usage: createUsageReporter (openvibe-sdk/usage), the shared platform.usage-sample@1 reporter \u2014 validated readings queued idempotency-keyed in an outbox and relayed to Billing with retry, never dropped, refused readings kept",
"the HTTP telemetry kit (openvibe-sdk/service): the platform.telemetry-sample@1 middleware aggregated per route|method|status_class per flush, with active-requests, p95 and event-loop-lag gauges, lifted from Network's server/telemetry.js and server/observability.js",
"the telemetry sink (openvibe-sdk/telemetry): telemetrySchema() DDL, createPgTelemetrySink() and pruneTelemetry(), a per-service PostgreSQL table every service stores platform.telemetry-sample@1 into",
"account-data (openvibe-sdk/account-data, ADR-033): telemetry_samples covered once by the SDK (exported as telemetry.json, anonymized on deletion), and uncovered() listing every table with a person column that no table map, task-keyed list or reasoned exemption covers",
@@@ -42,8 +44,8 @@
"the AI Exchange face of the one planner (openvibe-sdk/placement planAi, off by default): the hard gates (exact artifact, commercial right, privacy class, trust tier, capabilities, context, health, cost ceiling), the expected cost per success in integer nano-USD, sparse quality evidence marked and not trusted, and an explanation that never carries a secret or a host; the money math in openvibe-sdk/cost (nanoUsd, chargeTokens, expectedCostNano, costPerSuccessNano)",
"consent (openvibe-sdk/consent): the L24 check before any use of a person's content or likeness for AI or data; allowed() and filter() (batches of 500), onChange() drops a subject's answers on network.consent.changed, mock() for tests; fails closed, caches 60 s, server-side",
"openvibe-sdk/machines: the network's machines hub (list, get, catalogue, pair, rename, roles, revoke) against OpenVibe.Network, for a person and for the apps they consented to",
-- "vibes (openvibe-sdk/vibes): Vibes Pay charges (create with the app reference as the Idempotency-Key, get, refund) and constant-time webhook verification (X-OpenVibe-Signature-V2, ±300 s) for developer apps",
-- "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"
++ "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, \u00b1300 s) for developer apps",
++ "streams (openvibe-sdk/streams): the Agent Streams plan S1 \u2014 start an agent stream on OpenVibe.Live (live.agent-stream@1) with its publish ticket, renew and revoke that ticket (the kill switch), poll state, post the last frame, set the curtain, switch visibility, end it and mint the view ticket; the publish ticket is a bearer of its own on the desk routes"
],
"updated": "2026-11-06"
}
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex e74b67c..c5cf3cb 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.93.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.94.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.93.0';
++const SDK_VERSION = '0.94.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex e7f709d..77b66c9 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -888,6 +888,10 @@
"openvibe-sdk/registry": {
"createRegistryClient": "function"
},
++ "openvibe-sdk/reports": {
++ "cleanReport": "function",
++ "createReportsClient": "function"
++ },
"openvibe-sdk/resources": {
"RESOURCE_KINDS": "array",
"createResourceClient": "function",
ddiff --git a/docs/streams.md b/docs/streams.md
iindex 0220817..4792f65 100644
---- a/docs/streams.md
++++ b/docs/streams.md
@@@ -68,3 +68,60 @@ await streams.end(stream.id); // the card stays, with t
The person's own session token, or a first-party service token acting for that person: a Node principal acting for its
owner (Network issues the node token with `live.agent_stream.publish` for the audience `openvibe.live` and names its
owner in `on_behalf_of`), Codes or Actor. Never a developer-app token, never another person's stream.
++
++## Schedules (S7)
++
++An owner may schedule a public agent stream: a time, a task template and the mode. The record shows on the channel's
++Agents tab and in Today 24 hours before its time. The owner's online desk claims it at the time, which starts the
++stream; a desk that is offline leaves `missed` with `desk_offline` ("didn't start: the desk was offline") and nothing
++runs. A `chatplays` schedule waits for the owner's Go.
++
++```js
++const { schedule } = await streams.schedule({
++ title: 'Nightly dependency update',
++ task: { source: 'codes', ref: 'tsk_…', title: 'Update the dependencies' },
++ mode: 'narrated',
++ startsAt: '2026-11-08T19:00:00Z',
++});
++await streams.listSchedules({ mine: true });
++await streams.cancelSchedule(schedule.id); // before it starts
++await streams.remindSchedule(schedule.id); // opt in to the reminder; the answer is a count
++```
++
++## Steps, the digest, polls and the controls (S7)
++
++```js
++// Any harness: one step at a time, from a publish ticket or the person's own session.
++await streams.steps.post(stream.id, { title: 'Run the tests', status: 'started' });
++await streams.steps.post(stream.id, { title: 'Run the tests', status: 'passed', tests: { passed: 12, failed: 0 } },
++ { ticket: publish.ticket });
++
++// A Co-pilot's service token (chat.agent_digest.read / chat.agent_poll.write).
++const digest = await streams.digest.read(stream.id);
++const poll = await streams.poll.open(stream.id, { question: 'Which fix first?', options: ['A', 'B'], seconds: 60 });
++
++// Honour the four controls: pause, curtain, mute, end.
++const stop = streams.onControl(stream.id, (control) => console.log(control.control, control.by.handle));
++// later: stop()
++```
++
++## Reports
++
++`openvibe-sdk/reports` writes a session report with the person's own token, private by default:
++
++```js
++const { createReportsClient } = require('openvibe-sdk/reports');
++const reports = createReportsClient(createClient({ tokenProvider }));
++const { id, url } = await reports.write({
++ source: 'node', task: { ref: 'run_123', title: 'Fix the login page' },
++ summary: 'The login page redirects again; the tests are green.', outcome: 'done',
++});
++```
++
++## The example
++
++`examples/cli-agent-stream.js` streams any CLI agent's run with steps in about 40 lines:
++
++```bash
++OPENVIBE_TOKEN=… node examples/cli-agent-stream.js --title "Fix the login page" -- npm test
++```
ddiff --git a/esm/reports.mjs b/esm/reports.mjs
nnew file mode 100644
iindex 0000000..e36333e
---- /dev/null
++++ b/esm/reports.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/reports.js. Do not edit.
++import cjs from '../src/reports.js';
++
++export const {
++ createReportsClient, cleanReport,
++} = cjs;
++export default cjs;
ddiff --git a/examples/cli-agent-stream.js b/examples/cli-agent-stream.js
nnew file mode 100644
iindex 0000000..d7cd055
---- /dev/null
++++ b/examples/cli-agent-stream.js
@@@ -0,0 +1,50 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Stream any CLI agent's run on OpenVibe.Live with steps (Agent Streams S7).
++ *
++ * OPENVIBE_TOKEN=… node examples/cli-agent-stream.js --title "Fix the login page" -- npm test
++ *
++ * The same path a Node desk uses: startAgentStream gives the stream and its publish ticket, every line the command
++ * writes is a step, and the stream ends with the command's result. Private by default; pass --public to list it.
++ */
++const { spawn } = require('node:child_process');
++const { createClient } = require('../src/core');
++const { createStreamsClient } = require('../src/streams');
++
++const args = process.argv.slice(2);
++const flag = (name, fallback) => {
++ const i = args.indexOf(name);
++ return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args.splice(i, 2)[1] : fallback;
++};
++const title = flag('--title', 'A CLI agent run');
++const visibility = args.includes('--public') ? 'public' : 'private';
++if (args.includes('--public')) args.splice(args.indexOf('--public'), 1);
++const command = args[0] === '--' ? args.slice(1) : args;
++if (!command.length) { console.error('usage: cli-agent-stream.js --title "…" [--public] -- <command>'); process.exit(2); }
++
++(async () => {
++ if (!process.env.OPENVIBE_TOKEN) { console.error('set OPENVIBE_TOKEN to the person\'s OpenVibe token'); process.exit(2); }
++ const baseUrl = String(process.env.OPENVIBE_LIVE_URL || '').trim();
++ const streams = createStreamsClient(createClient({ token: process.env.OPENVIBE_TOKEN, ...(baseUrl ? { baseUrls: { live: baseUrl } } : {}) }));
++ const { stream, publish } = await streams.startAgentStream({
++ title, visibility,
++ task: { source: 'node', ref: `cli_${Date.now()}`, title: command.join(' ') },
++ });
++ const step = async (t, status, extra = {}) => {
++ try { return await streams.steps.post(stream.id, { title: t, status, ...extra }, { ticket: publish.ticket }); }
++ catch (err) { console.error(`[step] ${err.message}`); }
++ };
++ await step('Started the command', 'started');
++ console.log(`Live: ${stream.page_url}`);
++ const child = spawn(command[0], command.slice(1), { stdio: ['inherit', 'pipe', 'pipe'] });
++ const line = (status) => (buf) => String(buf).trim().split('\n').slice(0, 3).forEach((t) => t && step(t.slice(0, 120), status));
++ child.stdout.on('data', line('started'));
++ child.stderr.on('data', line('failed'));
++ child.on('close', async (code) => {
++ await step(code === 0 ? 'The command passed' : `The command failed (exit ${code})`, code === 0 ? 'passed' : 'failed', { tests: { passed: code === 0 ? 1 : 0, failed: code === 0 ? 0 : 1 } });
++ await streams.end(stream.id);
++ console.log(`Ended: ${stream.page_url}`);
++ process.exit(code || 0);
++ });
++})().catch((err) => { console.error(`openvibe streams: ${err.message}`); process.exit(1); });
ddiff --git a/package-lock.json b/package-lock.json
iindex 3f4f22c..53a0bd7 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.93.0",
++ "version": "0.94.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.93.0",
++ "version": "0.94.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.298.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.305.0",
"pg": "^8.23.0"
},
"engines": {
@@@ -198,9 +198,9 @@
"license": "MIT"
},
"node_modules/openvibe-contracts": {
-- "version": "0.298.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
-- "integrity": "sha512-G6JmHxYQOKQ7kjjufsGBD1X9QeSgfHZT/UuDRbHPvJ8+FFHZ95nzlNLtbRkgBcW5AlgmmxBOq/1Tj80XbmlmAg==",
++ "version": "0.305.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.305.0",
++ "integrity": "sha512-jTU1zlnTmHlydwL3/c5HVdUiPpd8r2KucVdHYUYX5MZgJoqy4M7Hg2jy6UKbJQ6s665uIZyTvswbmJjB2uwc5Q==",
"dev": true,
"license": "AGPL-3.0-only",
"dependencies": {
ddiff --git a/package.json b/package.json
iindex e961231..7f1cd36 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.93.0",
++ "version": "0.94.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",
@@@ -493,6 +493,12 @@
"types": "./types/locker-vectors.d.ts",
"import": "./esm/locker-vectors.mjs",
"default": "./src/locker/vectors.js"
++ },
++ "./reports": {
++ "types": "./types/reports.d.ts",
++ "browser": null,
++ "import": "./esm/reports.mjs",
++ "default": "./src/reports.js"
}
},
"bin": {
@@@ -538,7 +544,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.298.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.305.0",
"pg": "^8.23.0"
},
"peerDependencies": {
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 09ed985..3d386bb 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -70,6 +70,7 @@ const ENTRIES = {
'quest.mjs': '../src/quest.js',
'live.mjs': '../src/live.js',
'streams.mjs': '../src/streams.js',
++ 'reports.mjs': '../src/reports.js',
'inventory.mjs': '../src/inventory.js',
'tips.mjs': '../src/tips.js',
'blog.mjs': '../src/blog.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 6ffbf9a..08abcec 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.93.0';
++const SDK_VERSION = '0.94.0';
module.exports = {
createClient,
ddiff --git a/src/reports.js b/src/reports.js
nnew file mode 100644
iindex 0000000..006660c
---- /dev/null
++++ b/src/reports.js
@@@ -0,0 +1,117 @@
++'use strict';
++/**
++ * openvibe-sdk/reports — a session report from any harness (the Agent Streams plan, S3/S7).
++ *
++ * A report is one agent session's story: what was asked, what came out, what it cost, how it ended. This client
++ * writes it with the person's own token, so a harness on their machine can report without a service token:
++ *
++ * const reports = createReportsClient(createClient({ tokenProvider }));
++ * const { id, url } = await reports.write({
++ * source: 'node', task: { ref: 'run_123', title: 'Fix the login page' },
++ * summary: 'The login page redirects again; the tests are green.',
++ * outcome: 'done', visibility: 'private',
++ * });
++ *
++ * A report is private by default (the design's rule). The Report service applies the person's default audience when
++ * `visibility` is absent and redacts every text field before it stores anything; this client sends only what the
++ * caller wrote and never a raw transcript.
++ */
++const ID_RE = /^rps_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SOURCES = ['codes', 'actor', 'desk', 'node'];
++const OUTCOMES = ['done', 'stopped', 'failed'];
++const VISIBILITIES = ['private', 'unlisted', 'public'];
++
++function text(value, max, what, required = true) {
++ if (value == null && !required) return undefined;
++ if (typeof value !== 'string' || (!value.trim() && required) || Array.from(value).length > max) {
++ throw new TypeError(`reports: ${what} is ${required ? '1–' : '0–'}${max} characters`);
++ }
++ return value;
++}
++
++function num(value, what, { min = 0, max = 1_000_000_000 } = {}) {
++ if (value == null) return undefined;
++ const n = Number(value);
++ if (!Number.isFinite(n) || n < min || n > max) throw new TypeError(`reports: ${what} is a number ${min}–${max}`);
++ return n;
++}
++
++function step(value) {
++ const s = value && typeof value === 'object' ? value : {};
++ const out = { title: text(s.title, 120, 'a step title'), status: ['started', 'passed', 'failed', 'skipped'].includes(s.status) ? s.status : 'started' };
++ if (s.at) out.at = new Date(String(s.at)).toISOString();
++ if (s.detail != null) out.detail = text(s.detail, 500, 'a step detail');
++ if (s.tests) out.tests = { passed: num(s.tests.passed, 'tests.passed') || 0, failed: num(s.tests.failed, 'tests.failed') || 0 };
++ if (s.cost_usd != null) out.cost_usd = num(s.cost_usd, 'cost_usd');
++ return out;
++}
++
++/** The body of POST /api/v1/me/session-reports, checked before anything leaves the process. */
++function cleanReport(report = {}) {
++ const r = report || {};
++ const task = r.task && typeof r.task === 'object' ? r.task : {};
++ const source = SOURCES.includes(r.source) ? r.source : null;
++ if (!source) throw new TypeError('reports: source is codes, actor, desk or node');
++ const out = {
++ source,
++ task: { ref: text(task.ref, 200, 'the task ref'), title: text(task.title, 200, 'the task title') },
++ summary: text(r.summary, 600, 'the summary'),
++ };
++ if (task.url) out.task.url = text(task.url, 2048, 'the task URL');
++ if (r.outcome !== undefined) out.outcome = OUTCOMES.includes(r.outcome) ? r.outcome : (() => { throw new TypeError('reports: outcome is done, stopped or failed'); })();
++ if (r.visibility !== undefined) out.visibility = VISIBILITIES.includes(r.visibility) ? r.visibility : (() => { throw new TypeError('reports: visibility is private, unlisted or public'); })();
++ if (r.worked_seconds != null) out.worked_seconds = Math.max(0, Math.round(num(r.worked_seconds, 'worked_seconds') || 0));
++ if (r.cost_usd != null) out.cost_usd = num(r.cost_usd, 'cost_usd');
++ if (r.started_at) out.started_at = new Date(String(r.started_at)).toISOString();
++ if (r.ended_at) out.ended_at = new Date(String(r.ended_at)).toISOString();
++ if (Array.isArray(r.timeline)) out.timeline = r.timeline.slice(0, 200).map(step);
++ if (Array.isArray(r.outputs)) {
++ out.outputs = r.outputs.slice(0, 50).map((o) => ({
++ kind: ['pr', 'file', 'page', 'deploy', 'clip', 'vod'].includes(o && o.kind) ? o.kind : 'file',
++ title: text(o && o.title, 200, 'an output title'),
++ ...(o && o.url ? { url: text(o.url, 2048, 'an output URL') } : {}),
++ ...(o && o.state ? { state: text(o.state, 60, 'an output state') } : {}),
++ }));
++ }
++ if (Array.isArray(r.highlights)) out.highlights = r.highlights.slice(0, 20).map((h) => text(h, 500, 'a highlight'));
++ if (r.proof && typeof r.proof === 'object') {
++ const p = r.proof;
++ out.proof = {
++ ...(p.tests ? { tests: { passed: num(p.tests.passed, 'proof tests passed') || 0, failed: num(p.tests.failed, 'proof tests failed') || 0 } } : {}),
++ ...(Array.isArray(p.screenshots) ? { screenshots: p.screenshots.slice(0, 20).filter((s) => typeof s === 'string') } : {}),
++ ...(p.review ? { review: { verdict: text(p.review.verdict, 40, 'the review verdict'), ...(p.review.notes ? { notes: text(p.review.notes, 2000, 'the review notes') } : {}) } } : {}),
++ };
++ }
++ return out;
++}
++
++/**
++ * createReportsClient(client, { baseUrl }) — POST /api/v1/me/session-reports on OpenVibe.Report with the person's token.
++ */
++function createReportsClient(client, { baseUrl } = {}) {
++ if (!client || typeof client.json !== 'function') throw new TypeError('createReportsClient: pass an openvibe-sdk/core client');
++ const at = { service: 'report', baseUrl, audience: 'openvibe.report' };
++ return {
++ /**
++ * Write a session report → { id, url, created }. Idempotent per (source, task ref): the same task answers the
++ * same report, so a retry never makes a second one. Private unless `visibility` says otherwise.
++ *
++ * @param {object} report
++ * @param {{ signal?: AbortSignal }} [options]
++ */
++ async write(report, { signal } = {}) {
++ // The idempotency key is the task, so the client's own retry cannot create a second report.
++ const body = cleanReport(report);
++ const key = `report:${body.source}:${body.task.ref}`.replace(/[^A-Za-z0-9:._-]/g, '_').slice(0, 160);
++ return client.json({ ...at, method: 'POST', path: '/api/v1/me/session-reports', json: body, idempotencyKey: key, signal });
++ },
++
++ /** One report the caller may see (a public one, or their own), or the 404 problem. */
++ async read(id, { signal } = {}) {
++ if (!ID_RE.test(String(id || ''))) throw new TypeError('reports: a report id is rps_…');
++ return client.json({ ...at, method: 'GET', path: `/api/v1/session-reports/${id}`, signal });
++ },
++ };
++}
++
++module.exports = { createReportsClient, cleanReport };
ddiff --git a/src/streams.js b/src/streams.js
iindex d58778d..98472da 100644
---- a/src/streams.js
++++ b/src/streams.js
@@@ -27,6 +27,9 @@ const VISIBILITIES = ['private', 'unlisted', 'public'];
const TASK_SOURCES = ['codes', 'actor', 'desk', 'node'];
const CURTAIN_REASONS = ['password_field', 'secret_file', 'env_print', 'manual', 'site', 'detector'];
const FRAME_TYPES = ['image/jpeg', 'image/webp'];
++const MODES = ['watch', 'narrated', 'copilot', 'chatplays'];
++const STEP_STATUSES = ['started', 'passed', 'failed', 'skipped'];
++const SCHEDULE_ID = /^scd_[0-9A-HJKMNP-TV-Z]{26}$/;
const MAX_FRAME_BYTES = 200 * 1024;
/** The frame's bytes, whatever the caller has: a Uint8Array, a Buffer or an ArrayBuffer. */
@@@ -65,6 +68,37 @@ function createStreamsClient(client, { baseUrl } = {}) {
return v;
};
++ const scheduleId = (v) => {
++ const id = String(v || '');
++ if (!SCHEDULE_ID.test(id)) throw new TypeError('streams: a schedule id is scd_…');
++ return id;
++ };
++ const cleanStep = (step = {}) => {
++ const r = step || {};
++ const body = {
++ title: text(r.title, 120, 'a step title'),
++ status: oneOf(r.status, STEP_STATUSES, 'the step status'),
++ };
++ if (r.at) body.at = new Date(r.at).toISOString();
++ if (r.detail !== undefined) body.detail = text(r.detail, 500, 'the step detail');
++ if (r.tests !== undefined) {
++ if (!r.tests || !Number.isInteger(r.tests.passed) || !Number.isInteger(r.tests.failed) || r.tests.passed < 0 || r.tests.failed < 0) {
++ throw new TypeError('streams: tests is { passed, failed }, whole numbers');
++ }
++ body.tests = { passed: r.tests.passed, failed: r.tests.failed };
++ }
++ if (r.cost_usd !== undefined) {
++ const n = Number(r.cost_usd);
++ if (!Number.isFinite(n) || n < 0) throw new TypeError('streams: cost_usd is a number, zero or more');
++ body.cost_usd = n;
++ }
++ return body;
++ };
++ const chatCall = ({ method = 'GET', ...opts }) => client.json({
++ service: 'chat', audience: 'openvibe.chat', method,
++ ...(method === 'GET' ? {} : { idempotencyKey: false }), ...opts,
++ });
++
return {
/**
* Start an agent's stream → { stream, publish }: the stream (live.agent-stream@1) and the publish part
@@@ -186,6 +220,149 @@ function createStreamsClient(client, { baseUrl } = {}) {
return call({ method: 'POST', path: `/api/streams/agent/${streamId(id)}/end`, signal });
},
++ /**
++ * Schedule a public agent stream (live.agent-stream-schedule@1): a time, a task template and the mode. The
++ * owner's online desk claims it at the time, which starts the stream; a desk that is offline leaves
++ * "didn't start: the desk was offline" and nothing runs. A chatplays schedule always waits for Go.
++ *
++ * @param {{ title: string, task: object, mode?: 'watch'|'narrated'|'copilot'|'chatplays',
++ * visibility?: 'private'|'unlisted'|'public', startsAt: string|Date }} request
++ * @param {{ signal?: AbortSignal }} [options]
++ */
++ async schedule(request = {}, { signal } = {}) {
++ const r = request || {};
++ const starts = r.startsAt instanceof Date ? r.startsAt : new Date(String(r.startsAt || ''));
++ if (Number.isNaN(starts.getTime())) throw new TypeError('streams: startsAt is a date-time');
++ return call({
++ method: 'POST', path: '/api/streams/agent/schedules',
++ json: {
++ title: text(r.title, 200, 'a title'),
++ task: r.task,
++ mode: oneOf(r.mode || 'watch', MODES, 'the mode'),
++ visibility: oneOf(r.visibility || 'public', VISIBILITIES, 'the visibility'),
++ starts_at: starts.toISOString(),
++ }, signal,
++ });
++ },
++
++ /** The caller's own schedules (`mine: true`) or a channel's public 24-hour window. */
++ async listSchedules({ channel = null, mine = false } = {}, { signal } = {}) {
++ const q = mine ? '?mine=1' : `?channel=${encodeURIComponent(String(channel || ''))}`;
++ if (!mine && !channel) throw new TypeError('streams: listSchedules needs a channel or mine: true');
++ return call({ method: 'GET', path: `/api/streams/agent/schedules${q}`, signal });
++ },
++
++ /** Cancel a schedule that has not started. */
++ async cancelSchedule(id, { signal } = {}) {
++ return call({ method: 'DELETE', path: `/api/streams/agent/schedules/${scheduleId(id)}`, signal });
++ },
++
++ /** Opt in to a schedule's reminder. The answer is the count, never the people. */
++ async remindSchedule(id, { signal } = {}) {
++ return call({ method: 'POST', path: `/api/streams/agent/schedules/${scheduleId(id)}/remind`, signal });
++ },
++
++ /**
++ * Post one step (live.agent-stream-step@1) from any agent harness: the overlay and the VOD's chapters read it.
++ * With a publish ticket the desk posts it; a first-party service token holding live.agent_stream.publish may
++ * post for its owner.
++ *
++ * @param {string} id the agent stream id (ags_…)
++ * @param {{ title: string, status: 'started'|'passed'|'failed'|'skipped', detail?: string,
++ * tests?: { passed: number, failed: number }, cost_usd?: number, at?: string|Date }} step
++ * @param {{ ticket?: string, signal?: AbortSignal }} [options]
++ */
++ async postStep(id, step, { ticket, signal } = {}) {
++ return call({
++ method: 'POST', path: `/api/streams/agent/${streamId(id)}/steps`, json: cleanStep(step),
++ ...(ticket ? { token: ticket } : {}), signal,
++ });
++ },
++
++ /** steps.post() — the design's spelling of postStep. */
++ steps: {
++ async post(id, step, { ticket, signal } = {}) {
++ return call({
++ method: 'POST', path: `/api/streams/agent/${streamId(id)}/steps`, json: cleanStep(step),
++ ...(ticket ? { token: ticket } : {}), signal,
++ });
++ },
++ },
++
++ /**
++ * The Co-pilot digest (S5): what the room said between two steps. Behind the token's chat.agent_digest.read
++ * capability; a person's token never holds it, so this is a service token's call.
++ *
++ * @param {string} id the agent stream id (ags_…)
++ * @param {{ since?: string, signal?: AbortSignal }} [options]
++ */
++ digest: {
++ async read(id, { since, signal } = {}) {
++ const q = since ? `?since=${encodeURIComponent(since)}` : '';
++ return chatCall({ method: 'GET', path: `/api/v1/rooms/agent:${streamId(id)}/digest${q}`, signal });
++ },
++ },
++
++ /**
++ * A poll the Co-pilot asks the room to run (S5), and its result. Behind chat.agent_poll.write.
++ *
++ * @param {string} id the agent stream id (ags_…)
++ * @param {{ question: string, options: string[], seconds?: number }} poll
++ * @param {{ signal?: AbortSignal }} [options]
++ */
++ poll: {
++ async open(id, { question, options, seconds = 60 } = {}, { signal } = {}) {
++ if (typeof question !== 'string' || !question.trim()) throw new TypeError('streams: a poll question is required');
++ if (!Array.isArray(options) || options.length < 2 || options.length > 4) throw new TypeError('streams: a poll has two to four options');
++ const n = Number(seconds);
++ if (!Number.isInteger(n) || n < 10 || n > 300) throw new TypeError('streams: a poll lasts 10–300 seconds');
++ return chatCall({
++ method: 'POST', path: `/api/v1/rooms/agent:${streamId(id)}/poll`,
++ json: { question: question.trim(), options: options.map((o) => String(o)), seconds: n }, signal,
++ });
++ },
++ async result(id, { signal } = {}) {
++ return chatCall({ method: 'GET', path: `/api/v1/rooms/agent:${streamId(id)}/poll`, signal });
++ },
++ },
++
++ /**
++ * Honour pause, curtain, mute and end: poll the stream's control log and call `handler(control)` for each new
++ * one. Returns a stop function; `signal` stops it too. The first poll only marks what is already there, so a
++ * harness that starts late does not replay yesterday's End.
++ *
++ * @param {string} id the agent stream id (ags_…)
++ * @param {(control: object) => void} handler
++ * @param {{ intervalMs?: number, signal?: AbortSignal }} [options]
++ */
++ onControl(id, handler, { intervalMs = 5000, signal } = {}) {
++ const stream = streamId(id);
++ if (typeof handler !== 'function') throw new TypeError('streams: onControl needs a handler');
++ let stopped = false;
++ let timer = null;
++ let seen = null;
++ const key = (c) => `${c.at}|${c.control}|${(c.by && c.by.handle) || ''}`;
++ const tick = async () => {
++ if (stopped) return;
++ try {
++ const out = await call({ method: 'GET', path: `/api/streams/agent/${stream}/controls`, signal });
++ const list = (out && out.controls) || [];
++ if (seen === null) { seen = new Set(list.map(key)); }
++ else {
++ for (const c of [...list].reverse()) {
++ if (seen.has(key(c))) continue;
++ seen.add(key(c));
++ try { handler(c); } catch { /* the harness's own error */ }
++ }
++ }
++ } catch { /* a poll failure is retried next time */ }
++ if (!stopped) { timer = setTimeout(tick, Math.max(1000, Number(intervalMs) || 5000)); if (timer.unref) timer.unref(); }
++ };
++ tick();
++ if (signal) signal.addEventListener('abort', () => { stopped = true; if (timer) clearTimeout(timer); }, { once: true });
++ return () => { stopped = true; if (timer) clearTimeout(timer); };
++ },
++
/**
* A view ticket (wvt_…, ten minutes) for the player's join message. The owner's own session gets one, and so
* does anyone the sharing system lets see a private stream.
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex af6860c..0f34107 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', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
++ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './chat-apps', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './reports', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windows
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
ddiff --git a/test/example-stream.test.js b/test/example-stream.test.js
nnew file mode 100644
iindex 0000000..5590a25
---- /dev/null
++++ b/test/example-stream.test.js
@@@ -0,0 +1,45 @@
++'use strict';
++// The S7 example streams a stub agent's steps end to end (design §8): the example starts the stream, posts the
++// command's output as steps, ends the stream, and every request the SDK made is what Live's routes expect.
++const assert = require('assert');
++const { spawn } = require('node:child_process');
++const path = require('node:path');
++const { stubServer, send } = require('./helpers');
++
++const AGS = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPS';
++const TICKET = 'wpt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
++
++(async () => {
++ const server = await stubServer(async (req, res, body) => {
++ const u = req.url.split('?')[0];
++ if (req.method === 'POST' && u === '/api/streams/agent') {
++ return send(res, 201, {
++ stream: { id: AGS, page_url: 'https://openvibe.live/@mira/agents/' + AGS, state: 'idle' },
++ publish: { whip_url: 'https://whip.openvibe.live/whip/agent/' + AGS, ticket: TICKET, expires_at: '2026-11-07T20:00:00.000Z' },
++ });
++ }
++ if (req.method === 'POST' && u === `/api/streams/agent/${AGS}/steps`) return send(res, 201, { step: { id: 1, ...JSON.parse(String(body)) } });
++ if (req.method === 'POST' && u === `/api/streams/agent/${AGS}/end`) return send(res, 200, { stream: { id: AGS, state: 'ended' } });
++ return send(res, 404, { error: 'not found' });
++ });
++ const child = spawn(process.execPath, [path.join(__dirname, '../examples/cli-agent-stream.js'), '--title', 'A stub agent', '--', process.execPath, '-e', "process.stdout.write('installed the packages\\nran the tests\\n')"], {
++ env: { ...process.env, OPENVIBE_TOKEN: 'person-token', OPENVIBE_LIVE_URL: server.url },
++ stdio: ['ignore', 'pipe', 'pipe'],
++ });
++ let out = '';
++ child.stdout.on('data', (b) => { out += String(b); });
++ child.stderr.on('data', (b) => { out += String(b); });
++ const code = await new Promise((ok) => child.on('close', ok));
++ assert.strictEqual(code, 0, out);
++ const paths = server.requests.map((r) => `${r.method} ${r.url.split('?')[0]}`);
++ assert.ok(paths.includes('POST /api/streams/agent'), paths.join('\n'));
++ assert.ok(paths.filter((p) => p.endsWith('/steps')).length >= 3, 'the start, the command output and the result are steps');
++ assert.ok(paths.includes(`POST /api/streams/agent/${AGS}/end`), 'the stream ends with the command');
++ const steps = server.requests.filter((r) => r.url.endsWith('/steps')).map((r) => JSON.parse(String(r.body)));
++ assert.ok(steps.some((s) => s.title === 'Started the command' && s.status === 'started'));
++ assert.ok(steps.some((s) => s.status === 'passed' && s.tests && s.tests.passed === 1), 'the last step says the command passed');
++ assert.match(out, /Live: https:\/\/openvibe\.live\/@mira\/agents\//);
++ assert.match(out, /Ended:/);
++ await server.close();
++ console.log('the S7 example: a stub agent streams its steps end to end');
++})().catch((e) => { console.error(e && e.stack || e); process.exit(1); });
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 23e6ff1..6f9862d 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -68,7 +68,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 85); // 0.90.0 assertions, 0.91.0 streams, 0.92.0 chat-apps
++ assert.equal(subpaths.length, 86); // 0.90.0 assertions, 0.91.0 streams, 0.92.0 chat-apps, 0.94.0 reports
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/reports.test.js b/test/reports.test.js
nnew file mode 100644
iindex 0000000..8636151
---- /dev/null
++++ b/test/reports.test.js
@@@ -0,0 +1,80 @@
++'use strict';
++// openvibe-sdk/reports: a session report from any harness with the person's token (Agent Streams S3/S7). The write
++// is private unless the caller names an audience, carries no owner (the Report service decides it from the token),
++// and is idempotent per source and task ref through its key.
++const assert = require('assert');
++const { createClient } = require('../src/core');
++const { createReportsClient, cleanReport } = require('../src/reports');
++const { stubServer, send, run } = require('./helpers');
++
++(async () => {
++ const server = await stubServer(async (req, res, body) => {
++ const path = req.url.split('?')[0];
++ if (req.method === 'POST' && path === '/api/v1/me/session-reports') {
++ return send(res, 201, { id: 'rps_01JAB2C3D4E5F6G7H8J9K0MNPS', url: 'https://openvibe.report/s/rps_01JAB2C3D4E5F6G7H8J9K0MNPS', created: true });
++ }
++ if (req.method === 'GET' && path === '/api/v1/session-reports/rps_01JAB2C3D4E5F6G7H8J9K0MNPS') {
++ return send(res, 200, { id: 'rps_01JAB2C3D4E5F6G7H8J9K0MNPS', task: { title: 'Fix the login page' } });
++ }
++ return send(res, 404, { error: 'not found' });
++ });
++ const client = createClient({ baseUrls: { report: server.url }, token: 'person-token', retries: 0 });
++ const reports = createReportsClient(client);
++ const last = () => server.requests[server.requests.length - 1];
++
++ await run([
++ ['write posts the report with the person\'s token and no owner', async () => {
++ const out = await reports.write({
++ source: 'node', task: { ref: 'run_123', title: 'Fix the login page' },
++ summary: 'The login page redirects again; the tests are green.', outcome: 'done',
++ worked_seconds: 912, cost_usd: 0.42,
++ });
++ assert.strictEqual(last().method, 'POST');
++ assert.strictEqual(last().url, '/api/v1/me/session-reports');
++ assert.strictEqual(last().headers.authorization, 'Bearer person-token');
++ const body = JSON.parse(String(last().body));
++ assert.deepStrictEqual(body.task, { ref: 'run_123', title: 'Fix the login page' });
++ assert.strictEqual(body.summary, 'The login page redirects again; the tests are green.');
++ assert.strictEqual(body.outcome, 'done');
++ assert.strictEqual(body.visibility, undefined, 'private is the service default, not a guess here');
++ assert.strictEqual(body.owner, undefined, 'the Report service takes the owner from the token');
++ assert.ok(last().headers['idempotency-key'].startsWith('report:node:run_123'), 'idempotent per source and task');
++ assert.strictEqual(out.id, 'rps_01JAB2C3D4E5F6G7H8J9K0MNPS');
++ assert.ok(out.url.endsWith(out.id));
++ }],
++
++ ['a public report names its audience, and the timeline is clean', async () => {
++ await reports.write({
++ source: 'codes', task: { ref: 'tsk_2', title: 'A public run' }, summary: 'Two steps.',
++ visibility: 'public', timeline: [{ title: 'Run the tests', status: 'started' }, { title: 'Run the tests', status: 'passed', tests: { passed: 3, failed: 0 } }],
++ });
++ const body = JSON.parse(String(last().body));
++ assert.strictEqual(body.visibility, 'public');
++ assert.deepStrictEqual(body.timeline.map((s) => s.status), ['started', 'passed']);
++ }],
++
++ ['the guards run before anything leaves', async () => {
++ const before = server.requests.length;
++ await assert.rejects(() => reports.write({ source: 'browser', task: { ref: 'a', title: 'b' }, summary: 'x' }), /source/);
++ await assert.rejects(() => reports.write({ source: 'node', task: { ref: 'a', title: 'b' } }), /summary/);
++ await assert.rejects(() => reports.write({ source: 'node', task: { ref: 'a', title: 'b' }, summary: 'x', outcome: 'maybe' }), /outcome/);
++ await assert.rejects(() => reports.write({ source: 'node', task: { ref: 'a', title: 'b' }, summary: 'x', visibility: 'everyone' }), /visibility/);
++ await assert.rejects(() => reports.read('not-a-report'), /report id/);
++ assert.strictEqual(server.requests.length, before, 'nothing was sent');
++ }],
++
++ ['read gets one report the caller may see', async () => {
++ const r = await reports.read('rps_01JAB2C3D4E5F6G7H8J9K0MNPS');
++ assert.deepStrictEqual([last().method, last().url], ['GET', '/api/v1/session-reports/rps_01JAB2C3D4E5F6G7H8J9K0MNPS']);
++ assert.strictEqual(r.task.title, 'Fix the login page');
++ }],
++
++ ['cleanReport is the body the service sees, with a raw transcript never in it', async () => {
++ const body = cleanReport({ source: 'node', task: { ref: 'a', title: 'b' }, summary: 's', transcript: 'the raw words' });
++ assert.ok(!('transcript' in body), 'a harness cannot smuggle the raw transcript into a report');
++ }],
++ ]);
++
++ await server.close();
++ console.log('openvibe-sdk/reports: the write with the person\'s token, private by default, and the guards');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/streams.test.js b/test/streams.test.js
iindex 89c623e..0085dea 100644
---- a/test/streams.test.js
++++ b/test/streams.test.js
@@@ -8,6 +8,7 @@ const { createStreamsClient } = require('../src/streams');
const { stubServer, send, run } = require('./helpers');
const AGS = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const SCD = 'scd_01JAB2C3D4E5F6G7H8J9K0MNPQ';
const TICKET = 'wpt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
(async () => {
@@@ -33,9 +34,20 @@ const TICKET = 'wpt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
if (req.method === 'POST' && path === `/api/streams/agent/${AGS}/view-ticket`) {
return send(res, 201, { ticket: 'wvt_0123456789abcdefghijklmnop', expires_at: '2026-11-06T12:10:00.000Z' });
}
++ if (req.method === 'POST' && path === '/api/streams/agent/schedules') {
++ return send(res, 201, { schedule: { id: SCD, owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', channel: 'mira', title: 'Nightly', task: { source: 'codes', ref: 'tsk_1', title: 'Nightly' }, mode: 'watch', visibility: 'public', starts_at: '2026-11-08T19:00:00.000Z', state: 'scheduled', agent_stream_id: null, missed_reason: null, waits_for_go: false, started_at: null, ended_at: null, reminders: 0, page_url: 'https://openvibe.live/@mira/agents', created_at: '2026-11-07T02:00:00.000Z' } });
++ }
++ if (req.method === 'GET' && path === '/api/streams/agent/schedules') return send(res, 200, { schedules: [] });
++ if (req.method === 'DELETE' && path === `/api/streams/agent/schedules/${SCD}`) return send(res, 200, { schedule: { id: SCD, state: 'cancelled' } });
++ if (req.method === 'POST' && path === `/api/streams/agent/schedules/${SCD}/remind`) return send(res, 200, { reminders: 2, reminded: true });
++ if (req.method === 'POST' && path === `/api/streams/agent/${AGS}/steps`) return send(res, 201, { step: { id: 7, title: 'Run the tests', status: 'started', at: '2026-11-07T02:00:00.000Z' } });
++ if (req.method === 'GET' && path === `/api/v1/rooms/agent:${AGS}/digest`) return send(res, 200, { items: [], total: 0 });
++ if (req.method === 'POST' && path === `/api/v1/rooms/agent:${AGS}/poll`) return send(res, 201, { poll_id: 'pol_1', state: 'open' });
++ if (req.method === 'GET' && path === `/api/v1/rooms/agent:${AGS}/poll`) return send(res, 200, { poll_id: 'pol_1', state: 'closed' });
++ if (req.method === 'GET' && path === `/api/streams/agent/${AGS}/controls`) return send(res, 200, { controls: [], live: { paused: false } });
return send(res, 404, { error: 'not found', code: 'live.not_found' });
});
-- const client = createClient({ baseUrls: { live: server.url }, token: 'person-token', retries: 0 });
++ const client = createClient({ baseUrls: { live: server.url, chat: server.url }, token: 'person-token', retries: 0 });
const streams = createStreamsClient(client);
const last = () => server.requests[server.requests.length - 1];
@@@ -111,6 +123,63 @@ const TICKET = 'wpt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
assert.strictEqual(last().headers['content-type'], 'image/webp');
}],
++ ['S7: a schedule is written, listed, cancelled and reminded', async () => {
++ const made = await streams.schedule({
++ title: 'Nightly dependency update',
++ task: { source: 'codes', ref: 'tsk_1', title: 'Update the dependencies' },
++ mode: 'narrated',
++ startsAt: '2026-11-08T19:00:00Z',
++ });
++ assert.deepStrictEqual([last().method, last().url], ['POST', '/api/streams/agent/schedules']);
++ assert.deepStrictEqual(JSON.parse(String(last().body)), {
++ title: 'Nightly dependency update',
++ task: { source: 'codes', ref: 'tsk_1', title: 'Update the dependencies' },
++ mode: 'narrated', visibility: 'public', starts_at: '2026-11-08T19:00:00.000Z',
++ });
++ assert.strictEqual(made.schedule.id, SCD);
++ await streams.listSchedules({ channel: 'mira' });
++ assert.strictEqual(last().url, '/api/streams/agent/schedules?channel=mira');
++ await streams.listSchedules({ mine: true });
++ assert.strictEqual(last().url, '/api/streams/agent/schedules?mine=1');
++ await streams.cancelSchedule(SCD);
++ assert.deepStrictEqual([last().method, last().url], ['DELETE', `/api/streams/agent/schedules/${SCD}`]);
++ const reminder = await streams.remindSchedule(SCD);
++ assert.deepStrictEqual(reminder, { reminders: 2, reminded: true }, 'the answer is a count');
++ await assert.rejects(() => streams.cancelSchedule('ags_01JAB2C3D4E5F6G7H8J9K0MNPQ'), /schedule id/);
++ }],
++
++ ['S7: a step is posted by the desk or in the person\'s session', async () => {
++ await streams.postStep(AGS, { title: 'Run the tests', status: 'started' }, { ticket: TICKET });
++ assert.deepStrictEqual([last().method, last().url], ['POST', `/api/streams/agent/${AGS}/steps`]);
++ assert.strictEqual(last().headers.authorization, `Bearer ${TICKET}`);
++ assert.deepStrictEqual(JSON.parse(String(last().body)), { title: 'Run the tests', status: 'started' });
++ await streams.steps.post(AGS, { title: 'Tests passed', status: 'passed', tests: { passed: 12, failed: 0 } });
++ assert.deepStrictEqual(JSON.parse(String(last().body)), { title: 'Tests passed', status: 'passed', tests: { passed: 12, failed: 0 } });
++ assert.strictEqual(last().headers.authorization, 'Bearer person-token');
++ await assert.rejects(() => streams.steps.post(AGS, { title: 'x', status: 'done' }), /step status/);
++ await assert.rejects(() => streams.steps.post(AGS, { title: 'x', status: 'passed', tests: { passed: -1, failed: 0 } }), /tests/);
++ }],
++
++ ['S7: the digest and the poll are a Chat service token\'s calls', async () => {
++ await streams.digest.read(AGS, { since: '2026-11-07T01:00:00Z' });
++ assert.strictEqual(last().url, `/api/v1/rooms/agent:${AGS}/digest?since=2026-11-07T01%3A00%3A00Z`);
++ await streams.poll.open(AGS, { question: 'Which fix first?', options: ['A', 'B'], seconds: 60 });
++ assert.deepStrictEqual([last().method, last().url], ['POST', `/api/v1/rooms/agent:${AGS}/poll`]);
++ assert.deepStrictEqual(JSON.parse(String(last().body)), { question: 'Which fix first?', options: ['A', 'B'], seconds: 60 });
++ await streams.poll.result(AGS);
++ assert.deepStrictEqual([last().method, last().url], ['GET', `/api/v1/rooms/agent:${AGS}/poll`]);
++ await assert.rejects(() => streams.poll.open(AGS, { question: 'x', options: ['A'] }), /two to four/);
++ }],
++
++ ['S7: onControl polls the log and stops', async () => {
++ const seen = [];
++ const stop = streams.onControl(AGS, (c) => seen.push(c), { intervalMs: 1000 });
++ await new Promise((r) => setTimeout(r, 20));
++ assert.deepStrictEqual(seen, [], 'nothing new yet');
++ stop();
++ assert.strictEqual(typeof stop, 'function');
++ }],
++
['visibility, end and the view ticket', async () => {
await streams.setVisibility(AGS, 'unlisted');
assert.deepStrictEqual([last().method, last().url], ['PUT', `/api/streams/agent/${AGS}/visibility`]);
ddiff --git a/types/reports.d.ts b/types/reports.d.ts
nnew file mode 100644
iindex 0000000..b97adf9
---- /dev/null
++++ b/types/reports.d.ts
@@@ -0,0 +1,51 @@
++/** openvibe-sdk/reports: a session report from any agent harness (Agent Streams S3/S7). */
++type Opts = { signal?: AbortSignal };
++export type ReportSource = 'codes' | 'actor' | 'desk' | 'node';
++export type ReportOutcome = 'done' | 'stopped' | 'failed';
++export type ReportVisibility = 'private' | 'unlisted' | 'public';
++export interface ReportTask {
++ ref: string;
++ title: string;
++ url?: string;
++}
++export interface ReportStep {
++ at?: string | Date;
++ title: string;
++ status?: 'started' | 'passed' | 'failed' | 'skipped';
++ detail?: string;
++ tests?: { passed: number; failed: number };
++ cost_usd?: number;
++}
++export interface ReportOutput {
++ kind?: 'pr' | 'file' | 'page' | 'deploy' | 'clip' | 'vod';
++ title: string;
++ url?: string;
++ state?: string;
++}
++export interface SessionReportWrite {
++ source: ReportSource;
++ task: ReportTask;
++ summary: string;
++ outcome?: ReportOutcome;
++ visibility?: ReportVisibility;
++ worked_seconds?: number;
++ cost_usd?: number;
++ started_at?: string;
++ ended_at?: string;
++ timeline?: ReportStep[];
++ outputs?: ReportOutput[];
++ highlights?: string[];
++ proof?: {
++ tests?: { passed: number; failed: number };
++ screenshots?: string[];
++ review?: { verdict: string; notes?: string };
++ };
++}
++export interface ReportsClient {
++ /** Write a session report → { id, url, created }. Private unless visibility says otherwise. */
++ write(report: SessionReportWrite, opts?: Opts): Promise<{ id: string; url: string; created: boolean }>;
++ /** One report the caller may see. */
++ read(id: string, opts?: Opts): Promise<Record<string, unknown>>;
++}
++export declare function createReportsClient(client: object, opts?: { baseUrl?: string }): ReportsClient;
++export declare function cleanReport(report?: SessionReportWrite): Record<string, unknown>;
ddiff --git a/types/streams.d.ts b/types/streams.d.ts
iindex 776df6e..cf4ae1f 100644
---- a/types/streams.d.ts
++++ b/types/streams.d.ts
@@@ -65,5 +65,78 @@ export interface StreamsClient {
end(id: string, opts?: Opts): Promise<Record<string, unknown>>;
/** A view ticket for the player's join message (ten minutes). */
viewTicket(id: string, opts?: Opts): Promise<{ ticket: string; expires_at: string }>;
++ /** Schedule a public agent stream. */
++ schedule(request: ScheduleRequest, opts?: Opts): Promise<{ schedule: AgentSchedule }>;
++ /** The caller's own schedules, or a channel's public 24-hour window. */
++ listSchedules(query?: { channel?: string; mine?: boolean }, opts?: Opts): Promise<{ schedules: AgentSchedule[] }>;
++ /** Cancel a schedule that has not started. */
++ cancelSchedule(id: string, opts?: Opts): Promise<{ schedule: AgentSchedule }>;
++ /** Opt in to a schedule's reminder; the answer is the count. */
++ remindSchedule(id: string, opts?: Opts): Promise<{ reminders: number; reminded: boolean }>;
++ /** Post one step from any harness. */
++ postStep(id: string, step: AgentStepInput, opts?: Opts & { ticket?: string }): Promise<{ step: AgentStep }>;
++ /** steps.post() — the same. */
++ steps: { post(id: string, step: AgentStepInput, opts?: Opts & { ticket?: string }): Promise<{ step: AgentStep }> };
++ /** The Co-pilot digest (a service token with chat.agent_digest.read). */
++ digest: { read(id: string, opts?: Opts & { since?: string }): Promise<Record<string, unknown>> };
++ /** A poll the Co-pilot asks the room to run (chat.agent_poll.write). */
++ poll: {
++ open(id: string, poll: { question: string; options: string[]; seconds?: number }, opts?: Opts): Promise<Record<string, unknown>>;
++ result(id: string, opts?: Opts): Promise<Record<string, unknown>>;
++ };
++ /** Poll the control log; call handler for each new control. Returns a stop function. */
++ onControl(id: string, handler: (control: AgentControl) => void, opts?: Opts & { intervalMs?: number }): () => void;
++}
++export type AgentStreamMode = 'watch' | 'narrated' | 'copilot' | 'chatplays';
++export interface ScheduleRequest {
++ title: string;
++ task: AgentTask;
++ mode?: AgentStreamMode;
++ visibility?: AgentStreamVisibility;
++ startsAt: string | Date;
++}
++export interface AgentSchedule {
++ id: string;
++ owner: string;
++ channel: string;
++ title: string;
++ task: AgentTask;
++ mode: AgentStreamMode;
++ visibility: AgentStreamVisibility;
++ starts_at: string;
++ state: 'scheduled' | 'live' | 'ended' | 'missed' | 'cancelled';
++ agent_stream_id: string | null;
++ missed_reason: 'desk_offline' | 'task_not_ready' | 'waits_for_go' | null;
++ waits_for_go: boolean;
++ started_at: string | null;
++ ended_at: string | null;
++ reminders: number;
++ reminded?: boolean;
++ page_url: string;
++ created_at: string;
++}
++export interface AgentStepInput {
++ title: string;
++ status: 'started' | 'passed' | 'failed' | 'skipped';
++ detail?: string;
++ at?: string | Date;
++ tests?: { passed: number; failed: number };
++ cost_usd?: number;
++}
++export interface AgentStep {
++ id: number;
++ at: string;
++ title: string;
++ status: 'started' | 'passed' | 'failed' | 'skipped';
++ detail?: string;
++ tests?: { passed: number; failed: number };
++ cost_usd?: number;
++}
++export interface AgentControl {
++ control: 'pause' | 'curtain' | 'mute' | 'end';
++ by: { handle: string; role: 'owner' | 'moderator' };
++ at: string;
++ stream: string;
++ paused_by?: string | null;
}
export function createStreamsClient(client: { json(opts: Record<string, unknown>): Promise<any>; request(opts: Record<string, unknown>): Promise<any>; origin(service: string): Promise<string> }, options?: { baseUrl?: string }): StreamsClient;