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.
Files changed (67) hide show
  1. package/README.md +252 -13
  2. package/dist/atrium.d.ts +220 -7
  3. package/dist/atrium.d.ts.map +1 -1
  4. package/dist/atrium.js +410 -39
  5. package/dist/atrium.js.map +1 -1
  6. package/dist/dash.d.ts +206 -0
  7. package/dist/dash.d.ts.map +1 -0
  8. package/dist/dash.js +302 -0
  9. package/dist/dash.js.map +1 -0
  10. package/dist/direct.d.ts +86 -0
  11. package/dist/direct.d.ts.map +1 -0
  12. package/dist/direct.js +121 -0
  13. package/dist/direct.js.map +1 -0
  14. package/dist/index.d.ts +6 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +5 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/models.d.ts +70 -0
  19. package/dist/models.d.ts.map +1 -1
  20. package/dist/models.js +56 -0
  21. package/dist/models.js.map +1 -1
  22. package/dist/player/runtime.d.ts.map +1 -1
  23. package/dist/player/runtime.js +8 -1
  24. package/dist/player/runtime.js.map +1 -1
  25. package/dist/rooms/recorder.d.ts +4 -0
  26. package/dist/rooms/recorder.d.ts.map +1 -1
  27. package/dist/rooms/recorder.js +7 -0
  28. package/dist/rooms/recorder.js.map +1 -1
  29. package/dist/rooms/room.d.ts +84 -1
  30. package/dist/rooms/room.d.ts.map +1 -1
  31. package/dist/rooms/room.js +213 -4
  32. package/dist/rooms/room.js.map +1 -1
  33. package/dist/rooms/standing-source.d.ts +3 -0
  34. package/dist/rooms/standing-source.d.ts.map +1 -1
  35. package/dist/rooms/standing-source.js +6 -0
  36. package/dist/rooms/standing-source.js.map +1 -1
  37. package/dist/rooms/transcriber.d.ts +4 -0
  38. package/dist/rooms/transcriber.d.ts.map +1 -1
  39. package/dist/rooms/transcriber.js +1 -0
  40. package/dist/rooms/transcriber.js.map +1 -1
  41. package/dist/shaping/index.d.ts +1 -0
  42. package/dist/shaping/index.d.ts.map +1 -1
  43. package/dist/shaping/index.js +1 -0
  44. package/dist/shaping/index.js.map +1 -1
  45. package/dist/shaping/request.d.ts +1 -1
  46. package/dist/shaping/request.d.ts.map +1 -1
  47. package/dist/shaping/rooms.d.ts +2 -0
  48. package/dist/shaping/rooms.d.ts.map +1 -1
  49. package/dist/shaping/rooms.js +45 -6
  50. package/dist/shaping/rooms.js.map +1 -1
  51. package/dist/shaping/surveys.d.ts +69 -0
  52. package/dist/shaping/surveys.d.ts.map +1 -0
  53. package/dist/shaping/surveys.js +129 -0
  54. package/dist/shaping/surveys.js.map +1 -0
  55. package/dist/tools.d.ts +84 -0
  56. package/dist/tools.d.ts.map +1 -0
  57. package/dist/tools.js +105 -0
  58. package/dist/tools.js.map +1 -0
  59. package/dist/trace.d.ts +96 -0
  60. package/dist/trace.d.ts.map +1 -0
  61. package/dist/trace.js +251 -0
  62. package/dist/trace.js.map +1 -0
  63. package/dist/transcribe.d.ts +5 -0
  64. package/dist/transcribe.d.ts.map +1 -1
  65. package/dist/transcribe.js +1 -1
  66. package/dist/transcribe.js.map +1 -1
  67. 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
- ## Phase-1 surface
122
+ ## The client surface
93
123
 
94
- Beyond auth, the client exposes the library / assets / sources / sessions / rooms namespaces plus
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
- | `chat(messages, opts?)` | Chat completion. Pass `responseFormat` a **Zod** schema for validated structured output (Zod is an optional peer dep). |
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
- Not yet ported (environment- or broker-bound, tracked as parity exceptions): `record(into=session)`
129
- host bridging, and `audioVectors` / `audioScores` (the embed/chat-broker protocol). Audio-signal
130
- *consumption* is complete. In Node <22 pass a `WebSocketCtor` (e.g. from `ws`); browsers and Node ≥22
131
- use the global.
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`) plus the `play` /
170
- `layer.*` dialect helpers that back the hosted [Player tool](../../tools) — named, independently
171
- mixed WebAudio layers (music/ambient/SFX) with crossfades, reverb/echo/distance effects, and 3D
172
- positioning. It's the code an embedding app drives from resolved room signals; it owns the per-layer
173
- WebAudio graph and nothing about React or transport.
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
- * phase-1 namespaces (``library`` / ``assets`` / ``sources`` / ``sessions`` / ``rooms``) plus the
3
- * ``chat`` and ``embed`` methods. */
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
- * Chat completion (``POST /v1/chat/completions``). Pass ``responseFormat`` a **Zod schema** for
94
- * structured output: the reply is validated and the parsed value returned (with one repair turn,
95
- * then {@link StructuredOutputError}). A raw ``responseFormat`` dict passes through as an OpenAI
96
- * param. Streaming is a later pass.
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 {