hci-atrium 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +252 -13
- package/dist/atrium.d.ts +220 -7
- package/dist/atrium.d.ts.map +1 -1
- package/dist/atrium.js +410 -39
- package/dist/atrium.js.map +1 -1
- package/dist/dash.d.ts +206 -0
- package/dist/dash.d.ts.map +1 -0
- package/dist/dash.js +302 -0
- package/dist/dash.js.map +1 -0
- package/dist/direct.d.ts +86 -0
- package/dist/direct.d.ts.map +1 -0
- package/dist/direct.js +121 -0
- package/dist/direct.js.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/models.d.ts +70 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +56 -0
- package/dist/models.js.map +1 -1
- package/dist/player/runtime.d.ts.map +1 -1
- package/dist/player/runtime.js +8 -1
- package/dist/player/runtime.js.map +1 -1
- package/dist/rooms/recorder.d.ts +4 -0
- package/dist/rooms/recorder.d.ts.map +1 -1
- package/dist/rooms/recorder.js +7 -0
- package/dist/rooms/recorder.js.map +1 -1
- package/dist/rooms/room.d.ts +84 -1
- package/dist/rooms/room.d.ts.map +1 -1
- package/dist/rooms/room.js +213 -4
- package/dist/rooms/room.js.map +1 -1
- package/dist/rooms/standing-source.d.ts +3 -0
- package/dist/rooms/standing-source.d.ts.map +1 -1
- package/dist/rooms/standing-source.js +6 -0
- package/dist/rooms/standing-source.js.map +1 -1
- package/dist/rooms/transcriber.d.ts +4 -0
- package/dist/rooms/transcriber.d.ts.map +1 -1
- package/dist/rooms/transcriber.js +1 -0
- package/dist/rooms/transcriber.js.map +1 -1
- package/dist/shaping/index.d.ts +1 -0
- package/dist/shaping/index.d.ts.map +1 -1
- package/dist/shaping/index.js +1 -0
- package/dist/shaping/index.js.map +1 -1
- package/dist/shaping/request.d.ts +1 -1
- package/dist/shaping/request.d.ts.map +1 -1
- package/dist/shaping/rooms.d.ts +2 -0
- package/dist/shaping/rooms.d.ts.map +1 -1
- package/dist/shaping/rooms.js +45 -6
- package/dist/shaping/rooms.js.map +1 -1
- package/dist/shaping/surveys.d.ts +69 -0
- package/dist/shaping/surveys.d.ts.map +1 -0
- package/dist/shaping/surveys.js +129 -0
- package/dist/shaping/surveys.js.map +1 -0
- package/dist/tools.d.ts +84 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +105 -0
- package/dist/tools.js.map +1 -0
- package/dist/trace.d.ts +96 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/trace.js +251 -0
- package/dist/trace.js.map +1 -0
- package/dist/transcribe.d.ts +5 -0
- package/dist/transcribe.d.ts.map +1 -1
- package/dist/transcribe.js +1 -1
- package/dist/transcribe.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,6 +57,36 @@ function Dashboard() {
|
|
|
57
57
|
Atrium" button otherwise (pass `fallback` for your own landing page). The callback that Atrium
|
|
58
58
|
redirects back to is handled automatically — no route or effect to wire up.
|
|
59
59
|
|
|
60
|
+
### useRoomConnection
|
|
61
|
+
|
|
62
|
+
`useRoomConnection(code, { identity, baseUrl?, onRoom? })` is the connect/reconnect skeleton for a
|
|
63
|
+
component that consumes a [room](#live-rooms) by connect code — loginless, so it needs no provider
|
|
64
|
+
and no sign-in. It joins, classifies failures, and tears the handle down on unmount, returning
|
|
65
|
+
`{ status, room }`: `status` is `"connecting"` → `"connected"`, `"unreachable"` for a join/connect
|
|
66
|
+
failure, or a typed terminal end (`"bad-code"` / `"code-rotated"` / `"room-gone"`); `room` is the
|
|
67
|
+
`Room` handle, `null` before it exists and again after a terminal end. Register signal handlers in
|
|
68
|
+
`onRoom` — it runs the moment the handle exists, **before** `connect()`, so a replayed durable signal
|
|
69
|
+
isn't missed — and return a cleanup from it if you need one. `identity` is the presence name (e.g.
|
|
70
|
+
`"viewer:web"`); `baseUrl` defaults to `location.origin`.
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
import { useState } from "react"
|
|
74
|
+
import { useRoomConnection } from "hci-atrium/react"
|
|
75
|
+
|
|
76
|
+
function Scene({ code }: { code: string }) {
|
|
77
|
+
const [name, setName] = useState("—")
|
|
78
|
+
const { status, room } = useRoomConnection(code, {
|
|
79
|
+
identity: "viewer:web",
|
|
80
|
+
onRoom: (room) => {
|
|
81
|
+
room.on("scene.change", (e) => setName(String(e.payload.name)))
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
if (status !== "connected") return <p>{status}</p>
|
|
86
|
+
return <button onClick={() => void room?.send("applause")}>{name}</button>
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
60
90
|
## Core API (no framework)
|
|
61
91
|
|
|
62
92
|
```ts
|
|
@@ -89,10 +119,10 @@ const atrium = new Atrium({ baseUrl, clientId })
|
|
|
89
119
|
(retry once) on a 401, and concurrent callers share one refresh. If refresh fails, the SDK drops
|
|
90
120
|
to a signed-out state and emits a change.
|
|
91
121
|
|
|
92
|
-
##
|
|
122
|
+
## The client surface
|
|
93
123
|
|
|
94
|
-
Beyond auth, the client exposes the library / assets / sources / sessions / rooms
|
|
95
|
-
`chat` and `embed`. Every method takes an **options object** with `filters` kept nested
|
|
124
|
+
Beyond auth, the client exposes the library / assets / sources / sessions / rooms / surveys
|
|
125
|
+
namespaces plus `chat`, `agent` and `embed`. Every method takes an **options object** with `filters` kept nested
|
|
96
126
|
(`search("rain", { modality, filters: { … } })`); names are **camelCase**, translated to the wire's
|
|
97
127
|
snake_case in one place (the shaping layer). Authenticate with an OAuth `clientId` **or** an
|
|
98
128
|
`apiKey` — the latter works in the browser too (a one-time console note points at PKCE for hosted
|
|
@@ -108,7 +138,10 @@ apps).
|
|
|
108
138
|
| `sources.list()` / `sources.search(query, opts?)` / `sources.import(opts)` | External-provider gateway — read-only fan-out search, then import a `Candidate`. |
|
|
109
139
|
| `sessions.start(opts?)` / `sessions.resume(id)` / `sessions.event(id, type, opts?)` | Durable, append-only session logs. |
|
|
110
140
|
| `rooms.open(opts?)` / `rooms.get(id)` / `rooms.list()` / `rooms.join(code)` | Live [`Room`](#live-rooms) handles — `open`/`get`/`list` are authenticated, `join(code)` is a loginless participant. |
|
|
111
|
-
| `
|
|
141
|
+
| `surveys.start(template?, opts)` / `surveys.template(name, body, opts?)` / … | [Surveys](#surveys) on a session, and the project's survey templates. |
|
|
142
|
+
| `chat(messages, opts?)` | Chat completion. Pass `responseFormat` a **Zod** schema for validated structured output (Zod is an optional peer dep), or `stream: true` for an [`AsyncIterable<string>`](#streaming-chat) of deltas. |
|
|
143
|
+
| `agent(prompt, { tools, … })` | A [tool-calling loop](#agents-and-tools) over explicit tool descriptors. |
|
|
144
|
+
| `addDirectModel(name, { baseUrl, apiKey? })` | Route one model name straight to an OpenAI-compatible endpoint ([direct models](#direct-models)) instead of Atrium. |
|
|
112
145
|
| `embed(opts)` | Embed exactly one of `text` / `image` / `audio` / `video` into a vector; `model` is required. |
|
|
113
146
|
| `transcribe(audio, opts?)` | Speech-to-text over a multipart audio upload; `opts.verbose` fills `.language` / `.duration` / `.segments`. |
|
|
114
147
|
| `by.*` | Operand grammar for `searchByExamples` / `compare`: `by.asset` / `by.text` / `by.file` / `by.concept`, and `by.vector([…])` — the only way to pass a raw vector. |
|
|
@@ -125,15 +158,172 @@ a matching `room.request(...)`); `room.send(type, payload, { to })` fans out; pl
|
|
|
125
158
|
loudness/spectrum/beat stream. `connect()` opens the socket (every wire method calls it for you);
|
|
126
159
|
`close()` / `await using` / a passed `AbortSignal` tears it down.
|
|
127
160
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
161
|
+
Host-side surfaces on an **owned** room (`atrium.rooms.open/get/join`, not the loginless `joinRoom`):
|
|
162
|
+
|
|
163
|
+
| Member | Description |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| `room.record(into)` / `room.transcribe(opts?)` | Bridge the room into a durable session; run live transcription into `transcript.*` signals. |
|
|
166
|
+
| `room.audioVectors(opts?)` / `room.audioScores(anchors, opts?)` / `room.audioClips()` | Embed / score / take raw the live ~10s audio windows. |
|
|
167
|
+
| `room.audioTranscripts(opts?)` | The raw per-snippet transcript iterator — the speech-to-text sibling of `audioVectors`. Streaming when the backend offers it, per-clip batch otherwise (then each transcript carries its source window under `raw.snippet`). Takes `{ model, language, partials, skipEmpty, signal }`; `break` (or abort) ends the subscription. |
|
|
168
|
+
| `room.dashboard(opts?)` → [`Dashboard`](#observation-dashboard) | Host-side state for the hosted observation dashboard (`/tools/dashboard`). |
|
|
169
|
+
| `room.trace()` → `RoomTrace` | Mirror the SDK's own activity into the room as `trace.*` signals (default off). |
|
|
170
|
+
| `room.offerTranscription()` / `offerAudioCapture()` / `offerEmbeds()` / `offerChat()` | Standing services + capability brokers for code-joined peers. |
|
|
171
|
+
|
|
172
|
+
This SDK now covers every Python-SDK surface except the documented parity exceptions — the CLI, the
|
|
173
|
+
Home Assistant bridge, a sync surface, and raw-PCM mic input — each with its reason in
|
|
174
|
+
[`PARITY.md`](PARITY.md), alongside the handful of deliberate micro-divergences. In Node <22 pass a
|
|
175
|
+
`WebSocketCtor` (e.g. from `ws`); browsers and Node ≥22 use the global.
|
|
176
|
+
|
|
177
|
+
### Observation dashboard
|
|
178
|
+
|
|
179
|
+
`room.dashboard({ title })` returns a `Dashboard`: the app owns its state as a grid of **tiles** and
|
|
180
|
+
retells it — each joining dashboard gets a full `dash.snapshot`, every mutation broadcasts a
|
|
181
|
+
`dash.delta`. All `dash.*` traffic is ephemeral (the room stores nothing — the durable record is the
|
|
182
|
+
session). Tiles: `status(id, label, value, { style })` · `text(id, text)` · `image(id, { assetId,
|
|
183
|
+
url, caption })` · `sound(id, { assetId, playing })` · `decision(id, candidates, { picked,
|
|
184
|
+
reasoning })`, each taking `{ title, colSpan, rowSpan, group }` (spans 1..3). `error(message,
|
|
185
|
+
detail?)` pins the dashboard's sticky error strip — it is *not* a tile. Controls ride the snapshot:
|
|
186
|
+
`button(id, label, handler, { variant, confirm })`, `textInput(id, handler, { label, placeholder })`
|
|
187
|
+
(the handler's `{ ok?, message? }` is toasted), and `onNote(handler)` for researcher notes.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
const dash = room.dashboard({ title: "Reactive Soundscape" })
|
|
191
|
+
await dash.status("phase", "Phase", "Calibration", { style: "good" })
|
|
192
|
+
await dash.decision("pick", ["Forest Rain", "City Drizzle"], { picked: "Forest Rain" })
|
|
193
|
+
dash.button("advance", "Start next phase", async () => {
|
|
194
|
+
await dash.status("phase", "Phase", "Running")
|
|
195
|
+
return { message: "Phase advanced" }
|
|
196
|
+
})
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Tracing
|
|
200
|
+
|
|
201
|
+
`room.trace()` mirrors what the SDK is doing into the room, so a dashboard shows it without any
|
|
202
|
+
app-side plumbing. Library/model calls on the same client (`library.search` / `searchByExamples` /
|
|
203
|
+
`enrich` / `chat`) and `room.play` surface a `trace.<label>` (a one-line summary plus a capped
|
|
204
|
+
detail); recorder / transcriber / standing-offer transitions surface a `trace.state.<label>`. It is
|
|
205
|
+
**off by default** (it broadcasts app internals to anyone holding the connect code), costs nothing
|
|
206
|
+
while off, never breaks the traced call, and stops with `t.stop()`, `using t = room.trace()`, or the
|
|
207
|
+
room's teardown.
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
using t = room.trace()
|
|
211
|
+
await atrium.library.search("rain") // → trace.library.search on the dashboard
|
|
212
|
+
```
|
|
132
213
|
|
|
133
214
|
Errors derive from `AtriumError`: HTTP failures are `APIError` (401/403 → `AuthError`, 429/5xx →
|
|
134
215
|
`TransientAuthError`), and caller mistakes (mutually-exclusive inputs, unsupported filters) are
|
|
135
216
|
`InvalidArgumentError`.
|
|
136
217
|
|
|
218
|
+
### Surveys
|
|
219
|
+
|
|
220
|
+
A **survey** is a data object on a session, guarded by a per-object capability token: whoever holds
|
|
221
|
+
it can read and write exactly that object, nothing else. `surveys.start()` creates it — pinning the
|
|
222
|
+
template's *current* version, so later edits never change a running survey — and returns the token
|
|
223
|
+
plus the hosted tool's `url`. Hand either to the participant-facing environment, or `survey.open()`
|
|
224
|
+
a tab on the machine itself.
|
|
225
|
+
|
|
226
|
+
`surveys.template(name, body)` is the code-first **ensure** flow: run it on every start-up. It
|
|
227
|
+
creates the template if it's missing, appends a new immutable version only when the body actually
|
|
228
|
+
changed, and flips `default` when asked — so your templates live in your repo, not in a UI.
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
await atrium.surveys.template("post-study", postStudyBody, { default: true })
|
|
232
|
+
|
|
233
|
+
const session = await atrium.sessions.start({ participantRef: "p-07" })
|
|
234
|
+
const survey = await atrium.surveys.start("post-study", {
|
|
235
|
+
into: session,
|
|
236
|
+
returnTo: "https://study.example/next",
|
|
237
|
+
})
|
|
238
|
+
survey.open() // …or send survey.url / survey.token to the participant's device
|
|
239
|
+
|
|
240
|
+
const rows = await atrium.surveys.responses("post-study", { submittedOnly: true })
|
|
241
|
+
const csv = await atrium.surveys.responsesCsv("post-study") // Uint8Array, one row per response
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Everything project-scoped (`template` / `templates` / `responses` / `responsesCsv` / `uploadFile`)
|
|
245
|
+
takes `project` per call, or inherits the client's. `uploadFile(blob)` stores a write-once project
|
|
246
|
+
file (survey imagery) and returns the `id` a template body's `image` field references.
|
|
247
|
+
|
|
248
|
+
### Streaming chat
|
|
249
|
+
|
|
250
|
+
`chat(messages, { stream: true })` returns an `AsyncIterable<string>` of content deltas — **not** a
|
|
251
|
+
promise, so there is nothing to `await` before the loop. It works the same on a direct endpoint, and
|
|
252
|
+
can't be combined with a schema `responseFormat` (structured output validates a complete reply).
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
for await (const piece of atrium.chat("describe a rainy street", { stream: true })) {
|
|
256
|
+
process.stdout.write(piece)
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Agents and tools
|
|
261
|
+
|
|
262
|
+
`agent(prompt, { tools })` runs the tool-calling loop: the model may call your tools until it
|
|
263
|
+
answers, or until `maxTurns` (default 6). A tool is an explicit descriptor — TypeScript has no
|
|
264
|
+
runtime signatures or docstrings to derive a schema from, so what you write is what the model sees.
|
|
265
|
+
Pass `responseFormat` a Zod schema to structure the **final** answer (it applies after the loop —
|
|
266
|
+
json-mode conflicts with the tool-calling turns).
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
const reply = await atrium.agent("what should I play for a storm scene?", {
|
|
270
|
+
tools: [
|
|
271
|
+
{
|
|
272
|
+
name: "search_library",
|
|
273
|
+
description: "Find sounds in the Atrium library.",
|
|
274
|
+
parameters: {
|
|
275
|
+
type: "object",
|
|
276
|
+
properties: { query: { type: "string" } },
|
|
277
|
+
required: ["query"],
|
|
278
|
+
},
|
|
279
|
+
execute: async ({ query }) => {
|
|
280
|
+
const hits = await atrium.library.search(String(query), { modality: "audio", limit: 3 })
|
|
281
|
+
return hits.map((h) => h.asset.title)
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
})
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`parameters` is plain JSON Schema. To validate the model's arguments as well, pass
|
|
289
|
+
`{ jsonSchema, validate }` where `validate` is any [Standard Schema](https://standardschema.dev)
|
|
290
|
+
object (Zod ≥3.24, Valibot, ArkType): its parsed output is what `execute` receives. A *bare* schema
|
|
291
|
+
object is rejected — deriving JSON Schema from it would mean depending on that library, and this
|
|
292
|
+
package has no runtime dependencies.
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
import { z } from "zod"
|
|
296
|
+
|
|
297
|
+
const city = z.object({ city: z.string() })
|
|
298
|
+
const tool = {
|
|
299
|
+
name: "get_weather",
|
|
300
|
+
parameters: {
|
|
301
|
+
jsonSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
|
302
|
+
validate: city,
|
|
303
|
+
},
|
|
304
|
+
execute: ({ city }) => forecastFor(String(city)),
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Direct models
|
|
309
|
+
|
|
310
|
+
Register a model name to route its `chat` / `agent` calls **straight** to an OpenAI-compatible
|
|
311
|
+
endpoint — a local Ollama or LM Studio, or any remote server — instead of Atrium. A registered name
|
|
312
|
+
shadows an Atrium model of the same name (that's the point), and only chat is intercepted:
|
|
313
|
+
embeddings, search, sessions and rooms always go to Atrium.
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
atrium.addDirectModel("gemma3", { baseUrl: "http://localhost:11434/v1" })
|
|
317
|
+
await atrium.chat("hi", { model: "gemma3" }) // → localhost, not Atrium
|
|
318
|
+
|
|
319
|
+
await atrium.discoverModels("http://localhost:11434/v1") // what's installed? (registers nothing)
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Direct calls carry **only** that endpoint's own key (or none) — your Atrium credential is never sent
|
|
323
|
+
to a third-party endpoint. `discoverModels` is subject to browser CORS: a local server that sends no
|
|
324
|
+
`Access-Control-Allow-Origin` blocks the read before the SDK sees it (that's the endpoint's policy to
|
|
325
|
+
change, e.g. Ollama's `OLLAMA_ORIGINS`); from Node it just works.
|
|
326
|
+
|
|
137
327
|
## Quickstart (search + play)
|
|
138
328
|
|
|
139
329
|
```ts
|
|
@@ -166,11 +356,12 @@ room.audioSignals((sig) => {
|
|
|
166
356
|
|
|
167
357
|
## Player
|
|
168
358
|
|
|
169
|
-
The `hci-atrium/player` subpath ships the audio-layers engine (`LayerEngine`)
|
|
170
|
-
`layer.*` dialect helpers that back the hosted
|
|
171
|
-
mixed WebAudio layers (music/ambient/SFX) with
|
|
172
|
-
positioning. It's the code an embedding app drives
|
|
173
|
-
WebAudio graph and nothing about React or
|
|
359
|
+
The `hci-atrium/player` subpath ships the audio-layers engine (`LayerEngine`), the headless
|
|
360
|
+
[`PlayerRuntime`](#playerruntime), and the `play` / `layer.*` dialect helpers that back the hosted
|
|
361
|
+
[Player tool](../../tools) — named, independently mixed WebAudio layers (music/ambient/SFX) with
|
|
362
|
+
crossfades, reverb/echo/distance effects, and 3D positioning. It's the code an embedding app drives
|
|
363
|
+
from resolved room signals; it owns the per-layer WebAudio graph and nothing about React or
|
|
364
|
+
transport.
|
|
174
365
|
|
|
175
366
|
```ts
|
|
176
367
|
import { LayerEngine } from "hci-atrium/player"
|
|
@@ -183,6 +374,52 @@ engine.enqueue("music", [{ url: trackUrl }], "now")
|
|
|
183
374
|
See [`docs/reference/audio-layers-dialect.md`](../../docs/reference/audio-layers-dialect.md) for the
|
|
184
375
|
full wire dialect.
|
|
185
376
|
|
|
377
|
+
### PlayerRuntime
|
|
378
|
+
|
|
379
|
+
`PlayerRuntime` is the headless Player — the dialect brain of the hosted tool, lifted out of its
|
|
380
|
+
page. It owns the master chain (gain → limiter) *and* a `LayerEngine`, and dispatches the whole
|
|
381
|
+
`play` / `layer.*` dialect: hand it room frames via `handleSignal(frame)` and give it a `send` sink
|
|
382
|
+
for its outbound traffic (`layer.query` replies and `layer.state` broadcasts to `layer.subscribe`rs).
|
|
383
|
+
It never opens a socket, touches the DOM, or imports React — transport, UI, and
|
|
384
|
+
the intents it can't play itself (Spotify, "open link") stay with the host, the latter arriving
|
|
385
|
+
through the `unhandled` hook.
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
import { joinRoom } from "hci-atrium"
|
|
389
|
+
import { PlayerRuntime } from "hci-atrium/player"
|
|
390
|
+
|
|
391
|
+
const baseUrl = "https://atrium.example.edu"
|
|
392
|
+
const room = await joinRoom("ABC12345", { baseUrl })
|
|
393
|
+
const context = new AudioContext() // create it on the user's Start gesture — autoplay policy
|
|
394
|
+
|
|
395
|
+
const runtime = new PlayerRuntime({
|
|
396
|
+
context,
|
|
397
|
+
baseUrl, // asset-resolution root for the `asset_id`-only play shape
|
|
398
|
+
send: (type, payload, opts) => void room.send(type, payload as Record<string, unknown>, opts),
|
|
399
|
+
onLog: (line) => console.log(line),
|
|
400
|
+
onSnapshot: (layers) => renderLayers(layers), // engine snapshot → your UI
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
// Every inbound frame goes to the runtime; types outside the dialect fall through to `unhandled`.
|
|
404
|
+
room.onAny((e) =>
|
|
405
|
+
runtime.handleSignal({
|
|
406
|
+
type: e.type,
|
|
407
|
+
payload: e.payload,
|
|
408
|
+
from: e.from ?? undefined,
|
|
409
|
+
ack_id: e.ackId ?? undefined,
|
|
410
|
+
}),
|
|
411
|
+
)
|
|
412
|
+
await room.connect()
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
`LayerEngine` is the mixer; `PlayerRuntime` is the mixer *plus* the dialect. It exposes
|
|
416
|
+
`runtime.engine` for listener-side controls (per-layer / master volume, normalization, device-change
|
|
417
|
+
resume) and `runtime.masterGain` as an analyzer tap (pre-limiter); `dispose()` tears down the engine,
|
|
418
|
+
its timers, and the master chain it built, leaving the host's `AudioContext` alone. One caveat on the
|
|
419
|
+
`send` sink: `Room.send` carries `to` and `ephemeral` but not `replyTo`, so a host that must answer a
|
|
420
|
+
peer's `layer.query` builds the envelope itself — `buildSignalEnvelope` from the same subpath does
|
|
421
|
+
exactly that for a `POST /v1/rooms/{id}/signal` transport, which is what the hosted Player runs on.
|
|
422
|
+
|
|
186
423
|
## Honest caveat: the guard is UX, not secrecy
|
|
187
424
|
|
|
188
425
|
For a static single-page app the client-side guard is **user experience, not a security boundary**
|
|
@@ -199,3 +436,5 @@ pnpm build # tsc → dist/ (.js + .d.ts)
|
|
|
199
436
|
pnpm test # vitest (mocked fetch, no live server)
|
|
200
437
|
pnpm lint # biome check
|
|
201
438
|
```
|
|
439
|
+
|
|
440
|
+
Releases are published to npm by hand from the maintainer's account — there is no CI publish job.
|
package/dist/atrium.d.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/** The ``Atrium`` client — the entry point that owns auth, an authenticated transport, and the
|
|
2
|
-
*
|
|
3
|
-
* ``chat``
|
|
2
|
+
* namespaces (``library`` / ``assets`` / ``sources`` / ``sessions`` / ``rooms`` / ``surveys``) plus
|
|
3
|
+
* the ``chat`` / ``agent`` / ``embed`` methods and the direct-model registry. */
|
|
4
4
|
import { Auth } from "./auth.js";
|
|
5
5
|
import type { OperandLike } from "./by.js";
|
|
6
|
+
import { DirectEndpoint, type DirectEndpointInit } from "./direct.js";
|
|
6
7
|
import { DEFAULT_BASE_URL } from "./env.js";
|
|
7
8
|
import type { FileSource } from "./files.js";
|
|
8
9
|
import type { Candidate } from "./models.js";
|
|
9
|
-
import { type Asset, type AssetBinder, type AssetPage, type AssetRef, type AssetVector, type AssetVectorsOptions, type ChatReply, type CompareResult, type Filters, type IndexEntry, type SessionEvent as ReadSessionEvent, type SearchByExamplesHit, type SearchHit, type SegmentRef, type Session, type Event as SessionEvent, type SessionInfo, type SourceInfo, type SourceSearchResult, type Space, type TagDefinition, type Transcript } from "./models.js";
|
|
10
|
+
import { type Asset, type AssetBinder, type AssetPage, type AssetRef, type AssetVector, type AssetVectorsOptions, type ChatReply, type CompareResult, type Filters, type IndexEntry, type ProjectFile, type SessionEvent as ReadSessionEvent, type SearchByExamplesHit, type SearchHit, type SegmentRef, type Session, type Event as SessionEvent, type SessionInfo, type SourceInfo, type SourceSearchResult, type Space, type Survey, type SurveyResponse, type SurveyTemplate, type TagDefinition, type Transcript } from "./models.js";
|
|
10
11
|
import { type FetchImpl, Room, type RoomTransportMode, type WebSocketCtor } from "./rooms/index.js";
|
|
11
12
|
import { type ChatMessage, type SessionExportFormat, type ShapedRequest, type ZodLike } from "./shaping/index.js";
|
|
12
13
|
export type { SessionExportFormat } from "./shaping/rooms.js";
|
|
14
|
+
import { type ToolDescriptor } from "./tools.js";
|
|
15
|
+
import { type TraceSink } from "./trace.js";
|
|
13
16
|
import { type AudioChunk, type TranscribeStreamOptions } from "./transcribe.js";
|
|
14
17
|
export { DEFAULT_BASE_URL };
|
|
15
18
|
export interface AtriumOptions {
|
|
@@ -27,6 +30,13 @@ export interface AtriumOptions {
|
|
|
27
30
|
project?: string;
|
|
28
31
|
/** Default collection scope applied to calls that don't pass their own ``collection``. */
|
|
29
32
|
collection?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Model names to route **straight** to an OpenAI-compatible endpoint instead of Atrium, e.g.
|
|
35
|
+
* ``{ gemma3: { baseUrl: "http://localhost:11434/v1" } }``. Only ``chat`` / ``agent`` consult this
|
|
36
|
+
* registry — embeddings and everything else always go to Atrium. See
|
|
37
|
+
* {@link Atrium.addDirectModel}.
|
|
38
|
+
*/
|
|
39
|
+
directModels?: Record<string, DirectEndpoint | DirectEndpointInit>;
|
|
30
40
|
}
|
|
31
41
|
/** The internal transport the namespaces build on. */
|
|
32
42
|
interface ClientInternal {
|
|
@@ -54,6 +64,11 @@ interface ClientInternal {
|
|
|
54
64
|
readonly library: Library;
|
|
55
65
|
/** The shared live-transcription engine — behind ``transcribeLive`` and a room's ``transcribe``. */
|
|
56
66
|
transcribeStream(clips: AsyncIterable<AudioChunk>, opts: TranscribeStreamOptions): AsyncIterable<Transcript>;
|
|
67
|
+
/** The batch transcription route — a room's ``audioTranscripts`` degraded path POSTs each snippet. */
|
|
68
|
+
transcribe(audio: FileSource, opts?: TranscribeOptions): Promise<Transcript>;
|
|
69
|
+
/** Tracing sinks armed by ``room.trace()`` — every traced library/model call on this client mirrors
|
|
70
|
+
* into each. Empty (the fast path) until a room arms one. */
|
|
71
|
+
readonly traceSinks: TraceSink[];
|
|
57
72
|
}
|
|
58
73
|
/**
|
|
59
74
|
* A client bound to one Atrium instance and one credential (an OAuth ``clientId`` or an ``apiKey``).
|
|
@@ -73,11 +88,18 @@ export declare class Atrium implements ClientInternal, AsyncDisposable {
|
|
|
73
88
|
readonly sources: Sources;
|
|
74
89
|
readonly sessions: Sessions;
|
|
75
90
|
readonly rooms: Rooms;
|
|
91
|
+
readonly surveys: Surveys;
|
|
92
|
+
/** Tracing sinks armed by ``room.trace()`` (shared, mutated by the room handle) — every traced
|
|
93
|
+
* library/model call mirrors into each. Empty until a room arms one: that emptiness IS the
|
|
94
|
+
* zero-cost-when-off fast path. */
|
|
95
|
+
readonly traceSinks: TraceSink[];
|
|
76
96
|
private readonly apiKey;
|
|
77
97
|
private readonly defaultProject;
|
|
78
98
|
private readonly defaultCollection;
|
|
79
99
|
private readonly lifecycle;
|
|
80
100
|
private spacesPromise;
|
|
101
|
+
/** Model name → OpenAI-compatible endpoint; consulted by ``chat`` / ``agent`` only. */
|
|
102
|
+
private readonly direct;
|
|
81
103
|
constructor(opts?: AtriumOptions);
|
|
82
104
|
/**
|
|
83
105
|
* ``fetch`` against this instance with the signed-in user's bearer token attached, silent refresh,
|
|
@@ -90,16 +112,95 @@ export declare class Atrium implements ClientInternal, AsyncDisposable {
|
|
|
90
112
|
close(): Promise<void>;
|
|
91
113
|
[Symbol.asyncDispose]: () => Promise<void>;
|
|
92
114
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
115
|
+
* Route ``name`` straight to an OpenAI-compatible endpoint instead of Atrium.
|
|
116
|
+
*
|
|
117
|
+
* ``chat`` / ``agent`` calls naming ``name`` POST directly to ``baseUrl`` (bypassing Atrium, and
|
|
118
|
+
* carrying **only** that endpoint's own key — never the Atrium credential); everything else —
|
|
119
|
+
* embeddings, library search, sessions, rooms — still goes to Atrium. A registered name
|
|
120
|
+
* **shadows** an Atrium model of the same name (intentional, opt-in). Idempotent: re-registering
|
|
121
|
+
* a name overwrites its endpoint.
|
|
122
|
+
*/
|
|
123
|
+
addDirectModel(name: string, endpoint: DirectEndpointInit): void;
|
|
124
|
+
/**
|
|
125
|
+
* Register several model names sharing one endpoint (see {@link addDirectModel}) — the common
|
|
126
|
+
* "one server, many names" case. Still explicit per name; no auto-enumeration.
|
|
127
|
+
*/
|
|
128
|
+
addDirectModels(names: string[], endpoint: DirectEndpointInit): void;
|
|
129
|
+
/** Stop routing ``name`` directly — subsequent calls go to Atrium (idempotent). */
|
|
130
|
+
removeDirectModel(name: string): void;
|
|
131
|
+
/** A snapshot of the registered direct models: name → {@link DirectEndpoint}. */
|
|
132
|
+
listDirectModels(): Record<string, DirectEndpoint>;
|
|
133
|
+
/**
|
|
134
|
+
* List the model names an OpenAI-compatible endpoint advertises (read-only ``GET {baseUrl}/models``).
|
|
135
|
+
*
|
|
136
|
+
* A helper for *choosing* names to register with {@link addDirectModel} — it **never** registers
|
|
137
|
+
* anything and doesn't touch the registry.
|
|
138
|
+
*
|
|
139
|
+
* **In the browser this is subject to CORS**: a local Ollama / LM Studio typically sends no
|
|
140
|
+
* ``Access-Control-Allow-Origin``, so the fetch is blocked before the SDK sees a response. That is
|
|
141
|
+
* the endpoint's policy to change (e.g. Ollama's ``OLLAMA_ORIGINS``), not something the SDK can
|
|
142
|
+
* work around — from Node it just works.
|
|
143
|
+
*/
|
|
144
|
+
discoverModels(baseUrl: string, opts?: {
|
|
145
|
+
apiKey?: string;
|
|
146
|
+
signal?: AbortSignal;
|
|
147
|
+
}): Promise<string[]>;
|
|
148
|
+
/** The abort signal for one direct call: the caller's, the client lifecycle, and the endpoint's
|
|
149
|
+
* own timeout (``AbortSignal.timeout`` — the stateless-``fetch`` stand-in for Python's per-endpoint
|
|
150
|
+
* transport timeout), folded into one. */
|
|
151
|
+
private directSignal;
|
|
152
|
+
/** POST a chat body to a direct endpoint (raw ``fetch``, never the authed transport). */
|
|
153
|
+
private directChat;
|
|
154
|
+
/**
|
|
155
|
+
* Chat completion (``POST /v1/chat/completions``).
|
|
156
|
+
*
|
|
157
|
+
* Pass ``responseFormat`` a **Zod schema** for structured output: the reply is validated and the
|
|
158
|
+
* parsed value returned (with one repair turn, then {@link StructuredOutputError}). A raw
|
|
159
|
+
* ``responseFormat`` dict passes through as an OpenAI param. ``stream: true`` returns an
|
|
160
|
+
* ``AsyncIterable<string>`` of content deltas instead of a reply — ``for await`` over it (there is
|
|
161
|
+
* nothing to ``await`` first); it can't be combined with a schema ``responseFormat``.
|
|
162
|
+
*
|
|
163
|
+
* A ``model`` registered via {@link addDirectModel} is served directly by its endpoint,
|
|
164
|
+
* transparently: same return types and errors as the Atrium path.
|
|
97
165
|
*/
|
|
98
166
|
chat<T>(messages: string | ChatMessage[], opts: ChatOptions & {
|
|
99
167
|
responseFormat: ZodLike<T>;
|
|
100
168
|
}): Promise<T>;
|
|
169
|
+
chat(messages: string | ChatMessage[], opts: ChatOptions & {
|
|
170
|
+
stream: true;
|
|
171
|
+
}): AsyncIterable<string>;
|
|
101
172
|
chat(messages: string | ChatMessage[], opts?: ChatOptions): Promise<ChatReply>;
|
|
173
|
+
/** The chat body for one completion: the caller's passthrough params plus the SDK-owned keys. */
|
|
174
|
+
private chatParams;
|
|
175
|
+
private chatOnce;
|
|
176
|
+
/** Content deltas of a streamed completion (SSE, ``[DONE]``-terminated) — the Atrium route or,
|
|
177
|
+
* for a registered model, the direct endpoint. Mirror of Python's ``_chat_stream``. */
|
|
178
|
+
private chatStream;
|
|
102
179
|
private chatParse;
|
|
180
|
+
/**
|
|
181
|
+
* Validate ``reply`` into ``schema``; on failure run ONE tool-free json-mode repair turn, then give
|
|
182
|
+
* up. Shared by the ``chat`` and ``agent`` structured paths so the repair policy lives in exactly
|
|
183
|
+
* one place (mirror of Python's ``_parse_or_repair``). ``opts`` carries the caller's model /
|
|
184
|
+
* strictness / passthrough params, so the repair turn inherits them.
|
|
185
|
+
*/
|
|
186
|
+
private parseOrRepair;
|
|
187
|
+
/**
|
|
188
|
+
* Run a tool-using agent loop: the model may call the given tools until it answers.
|
|
189
|
+
*
|
|
190
|
+
* Each tool is an explicit {@link ToolDescriptor} — ``{ name, description, parameters, execute }``
|
|
191
|
+
* (TypeScript has no runtime signature/docstring to derive a schema from, unlike Python's
|
|
192
|
+
* ``@client.tool``). The loop calls the model, runs any requested tool, feeds the result back, and
|
|
193
|
+
* repeats until the model returns a final message or ``maxTurns`` (default 6) is reached.
|
|
194
|
+
*
|
|
195
|
+
* ``responseFormat`` takes a **Zod schema** to structure the **final** answer (json-mode conflicts
|
|
196
|
+
* with the tool-calling turns, so it applies only after the loop ends): the answer is parsed and
|
|
197
|
+
* validated — with one repair turn, then {@link StructuredOutputError} — and the value returned.
|
|
198
|
+
* Any other option (``temperature``, ``strictParams``, …) is forwarded to every ``chat`` call.
|
|
199
|
+
*/
|
|
200
|
+
agent<T>(prompt: string, opts: AgentOptions & {
|
|
201
|
+
responseFormat: ZodLike<T>;
|
|
202
|
+
}): Promise<T>;
|
|
203
|
+
agent(prompt: string, opts: AgentOptions): Promise<ChatReply>;
|
|
103
204
|
/**
|
|
104
205
|
* Embed text or a media file into a vector (``POST /v1/embeddings``). Pass exactly one of
|
|
105
206
|
* ``text`` / ``image`` / ``audio`` / ``video``. ``model`` is required — an embedding's model is
|
|
@@ -340,9 +441,57 @@ export interface ChatOptions {
|
|
|
340
441
|
model?: string;
|
|
341
442
|
responseFormat?: ZodLike | Record<string, unknown>;
|
|
342
443
|
strictParams?: boolean;
|
|
444
|
+
/** Return an ``AsyncIterable<string>`` of content deltas instead of a reply. */
|
|
445
|
+
stream?: boolean;
|
|
343
446
|
signal?: AbortSignal;
|
|
344
447
|
[key: string]: unknown;
|
|
345
448
|
}
|
|
449
|
+
export interface AgentOptions {
|
|
450
|
+
/** The tools the model may call this run (see {@link ToolDescriptor}). */
|
|
451
|
+
tools: ToolDescriptor[];
|
|
452
|
+
model?: string;
|
|
453
|
+
/** Cap on model turns before the loop gives up and returns the last reply (default 6). */
|
|
454
|
+
maxTurns?: number;
|
|
455
|
+
/** A **Zod schema** structuring the final answer (applied after the tool-calling turns). */
|
|
456
|
+
responseFormat?: ZodLike;
|
|
457
|
+
signal?: AbortSignal;
|
|
458
|
+
/** Extra OpenAI params (``temperature``, ``strictParams``, …) forwarded to every turn. */
|
|
459
|
+
[key: string]: unknown;
|
|
460
|
+
}
|
|
461
|
+
export interface SurveyStartOptions {
|
|
462
|
+
/** The session the survey is recorded on — a {@link Session} handle or its id. */
|
|
463
|
+
into: Session | string;
|
|
464
|
+
/** The data object's name (defaults to the template name, else ``"survey"``). */
|
|
465
|
+
name?: string;
|
|
466
|
+
/** A url the survey's end page can send the participant back to — an ``app``-controlled return
|
|
467
|
+
* button in the template resolves to it (the button's label stays template-controlled). */
|
|
468
|
+
returnTo?: string;
|
|
469
|
+
signal?: AbortSignal;
|
|
470
|
+
}
|
|
471
|
+
export interface SurveyTemplateOptions {
|
|
472
|
+
project?: string;
|
|
473
|
+
/** Make this the project's default template (the one ``start()`` picks with no template named). */
|
|
474
|
+
default?: boolean;
|
|
475
|
+
signal?: AbortSignal;
|
|
476
|
+
}
|
|
477
|
+
export interface SurveyResponsesOptions {
|
|
478
|
+
project?: string;
|
|
479
|
+
/** Filter by the response's session study phase (a string or list; ``""`` matches unset). */
|
|
480
|
+
phase?: string | string[];
|
|
481
|
+
/** Drop drafts, keeping only submitted responses. */
|
|
482
|
+
submittedOnly?: boolean;
|
|
483
|
+
/** Bound the newest-first result (server ceiling 500, default 200). */
|
|
484
|
+
limit?: number;
|
|
485
|
+
signal?: AbortSignal;
|
|
486
|
+
}
|
|
487
|
+
export interface SurveyUploadOptions {
|
|
488
|
+
project?: string;
|
|
489
|
+
/** The stored filename (defaults to a ``File``'s own name, else ``"upload"``). */
|
|
490
|
+
filename?: string;
|
|
491
|
+
/** The file's role in the project (default ``"attachment"``). */
|
|
492
|
+
role?: string;
|
|
493
|
+
signal?: AbortSignal;
|
|
494
|
+
}
|
|
346
495
|
export interface EmbedOptions {
|
|
347
496
|
model: string;
|
|
348
497
|
text?: string;
|
|
@@ -501,6 +650,70 @@ export declare class Sessions {
|
|
|
501
650
|
/** Append a durable event to a session's log (``POST /v1/sessions/{id}/events``). */
|
|
502
651
|
event(sessionId: string, type: string, opts?: SessionEventOptions): Promise<SessionEvent>;
|
|
503
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* Survey templates + starting surveys.
|
|
655
|
+
*
|
|
656
|
+
* A **survey** is a data object on a session (``POST /v1/sessions/{id}/data``) guarded by a
|
|
657
|
+
* per-object capability token: whoever holds the token can read and write exactly that object,
|
|
658
|
+
* nothing else. {@link Surveys.start} creates it — pinning a template's *current* version, so later
|
|
659
|
+
* template edits never change a running survey — and returns a {@link Survey} carrying the once-only
|
|
660
|
+
* ``token`` and the hosted tool ``url``.
|
|
661
|
+
*
|
|
662
|
+
* **Templates** are project-scoped JSON documents with immutable, numbered versions. The server
|
|
663
|
+
* stores the body verbatim — the hosted survey tool's dialect is documented in
|
|
664
|
+
* ``docs/reference/survey-template-dialect.md``. {@link Surveys.template} is the code-first "ensure"
|
|
665
|
+
* flow: safe to rerun on every start-up, appending a new version only when the body actually changed.
|
|
666
|
+
*/
|
|
667
|
+
export declare class Surveys {
|
|
668
|
+
private readonly c;
|
|
669
|
+
constructor(c: ClientInternal);
|
|
670
|
+
/** The project ref for a ``/v1/projects/{ref}/…`` path — the per-call value or the client's
|
|
671
|
+
* default scope, passed through **verbatim** (the survey/file routers accept a UUID or a short_id;
|
|
672
|
+
* resolving a short_id here would issue a projects read a project-scoped key is denied). */
|
|
673
|
+
private projectRef;
|
|
674
|
+
/**
|
|
675
|
+
* Start a survey on a session: creates the data object (pinning the template's current version —
|
|
676
|
+
* or the project's DEFAULT template when ``template`` is omitted) and returns the handle with its
|
|
677
|
+
* once-only token.
|
|
678
|
+
*/
|
|
679
|
+
start(template: string | undefined, opts: SurveyStartOptions): Promise<Survey>;
|
|
680
|
+
/**
|
|
681
|
+
* Ensure a template exists with exactly this body (idempotent, code-first).
|
|
682
|
+
*
|
|
683
|
+
* Create it if missing; if it exists and the CURRENT version's body differs, append a new
|
|
684
|
+
* immutable version; flip the default via PATCH when requested and not already set. Returns
|
|
685
|
+
* ``{name, version, isDefault}``. Safe to rerun on every start-up.
|
|
686
|
+
*/
|
|
687
|
+
template(name: string, body: unknown, opts?: SurveyTemplateOptions): Promise<{
|
|
688
|
+
name: string;
|
|
689
|
+
version: number;
|
|
690
|
+
isDefault: boolean;
|
|
691
|
+
}>;
|
|
692
|
+
/** List the project's survey templates (``GET …/survey-templates``). */
|
|
693
|
+
templates(opts?: {
|
|
694
|
+
project?: string;
|
|
695
|
+
signal?: AbortSignal;
|
|
696
|
+
}): Promise<SurveyTemplate[]>;
|
|
697
|
+
/**
|
|
698
|
+
* A survey template's responses across the project's sessions, newest first. ``template`` is the
|
|
699
|
+
* template **name** (not a started {@link Survey}), resolved with ``project`` exactly like
|
|
700
|
+
* {@link template} / {@link templates}. For the CSV export use {@link responsesCsv}.
|
|
701
|
+
*/
|
|
702
|
+
responses(template: string, opts?: SurveyResponsesOptions): Promise<SurveyResponse[]>;
|
|
703
|
+
/**
|
|
704
|
+
* The same responses as the server's CSV export (one row per response, question-id columns),
|
|
705
|
+
* returned as raw bytes. A separate method rather than a ``format`` union on {@link responses} —
|
|
706
|
+
* the return types have nothing in common, exactly like ``sessions.export``. The SDK never touches
|
|
707
|
+
* the filesystem, so a Node caller writes the bytes itself.
|
|
708
|
+
*/
|
|
709
|
+
responsesCsv(template: string, opts?: SurveyResponsesOptions): Promise<Uint8Array>;
|
|
710
|
+
/**
|
|
711
|
+
* Upload a write-once project file (survey imagery) — ``file`` is a ``Blob``/``File`` or a URL
|
|
712
|
+
* string, never a filesystem path (the core is browser-safe). Returns the file's ``id`` — which a
|
|
713
|
+
* template body's ``image`` field references — plus its public content ``url``.
|
|
714
|
+
*/
|
|
715
|
+
uploadFile(file: FileSource, opts?: SurveyUploadOptions): Promise<ProjectFile>;
|
|
716
|
+
}
|
|
504
717
|
/** Open / fetch / join rooms — each returns a live {@link Room} handle (connect, send, on, request,
|
|
505
718
|
* player controls, audio signals). ``connect()`` (called for you by any wire method) opens the socket. */
|
|
506
719
|
export declare class Rooms {
|