hci-atrium 0.3.1 → 0.5.0
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/GUIDE.md +1182 -0
- package/PARITY.md +385 -0
- package/README.md +96 -24
- package/bin/atrium.mjs +35 -0
- package/dist/atrium.d.ts +126 -42
- package/dist/atrium.d.ts.map +1 -1
- package/dist/atrium.js +192 -87
- package/dist/atrium.js.map +1 -1
- package/dist/dash.d.ts +3 -3
- package/dist/dash.d.ts.map +1 -1
- package/dist/dash.js +1 -2
- package/dist/dash.js.map +1 -1
- package/dist/env.d.ts +14 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +22 -0
- package/dist/env.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -3
- package/dist/index.js.map +1 -1
- package/dist/models.d.ts +84 -31
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +97 -25
- package/dist/models.js.map +1 -1
- package/dist/player/play-command.d.ts +6 -6
- package/dist/player/play-command.d.ts.map +1 -1
- package/dist/player/play-intent.d.ts.map +1 -1
- package/dist/player/play-intent.js +2 -1
- package/dist/player/play-intent.js.map +1 -1
- package/dist/player/runtime.d.ts +7 -3
- package/dist/player/runtime.d.ts.map +1 -1
- package/dist/player/runtime.js.map +1 -1
- package/dist/react/use-room-connection.d.ts +4 -1
- package/dist/react/use-room-connection.d.ts.map +1 -1
- package/dist/react/use-room-connection.js +5 -1
- package/dist/react/use-room-connection.js.map +1 -1
- package/dist/rooms/index.d.ts +3 -1
- package/dist/rooms/index.d.ts.map +1 -1
- package/dist/rooms/index.js +4 -3
- package/dist/rooms/index.js.map +1 -1
- package/dist/rooms/offer-transcription.d.ts +1 -1
- package/dist/rooms/offer-transcription.js +1 -1
- package/dist/rooms/owned-room.d.ts +251 -0
- package/dist/rooms/owned-room.d.ts.map +1 -0
- package/dist/rooms/owned-room.js +844 -0
- package/dist/rooms/owned-room.js.map +1 -0
- package/dist/rooms/recorder.d.ts +1 -1
- package/dist/rooms/recorder.js +1 -1
- package/dist/rooms/room.d.ts +82 -278
- package/dist/rooms/room.d.ts.map +1 -1
- package/dist/rooms/room.js +60 -854
- package/dist/rooms/room.js.map +1 -1
- package/dist/rooms/standing-source.d.ts +1 -1
- package/dist/rooms/standing-source.js +1 -1
- package/dist/rooms/transcriber.d.ts +3 -3
- package/dist/rooms/transcriber.d.ts.map +1 -1
- package/dist/rooms/transcriber.js +2 -2
- package/dist/rooms/transport.d.ts +1 -1
- package/dist/rooms/transport.js +2 -2
- package/dist/shaping/assets.d.ts +13 -0
- package/dist/shaping/assets.d.ts.map +1 -1
- package/dist/shaping/assets.js +27 -0
- package/dist/shaping/assets.js.map +1 -1
- package/dist/shaping/chat.d.ts +3 -1
- package/dist/shaping/chat.d.ts.map +1 -1
- package/dist/shaping/chat.js +4 -2
- package/dist/shaping/chat.js.map +1 -1
- package/dist/shaping/rooms.d.ts +78 -1
- package/dist/shaping/rooms.d.ts.map +1 -1
- package/dist/shaping/rooms.js +58 -6
- package/dist/shaping/rooms.js.map +1 -1
- package/dist/shaping/sources.d.ts +7 -3
- package/dist/shaping/sources.d.ts.map +1 -1
- package/dist/shaping/sources.js +9 -10
- package/dist/shaping/sources.js.map +1 -1
- package/dist/transcribe-stream.d.ts +1 -1
- package/dist/transcribe-stream.js +1 -1
- package/dist/transcribe.d.ts +2 -2
- package/dist/transcribe.js +2 -2
- package/package.json +18 -3
package/PARITY.md
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
# Python ↔ TypeScript SDK parity ledger
|
|
2
|
+
|
|
3
|
+
The workflow: **Python first, port in validated batches.** New surfaces land in the Python SDK,
|
|
4
|
+
prove themselves in use (an example, a study, a live test), then get ported here in one
|
|
5
|
+
well-scoped batch. This file is what keeps that honest — "port later" without a ledger is how
|
|
6
|
+
drift becomes silent.
|
|
7
|
+
|
|
8
|
+
Rules:
|
|
9
|
+
- When a Python-SDK surface merges that TS lacks, **append a row here** in the same MR/commit.
|
|
10
|
+
- When a batch is ported, check rows off (move to Done or delete — git remembers).
|
|
11
|
+
- A gap that is *deliberate* goes to "Won't port (by design)" with the reason — exceptions are
|
|
12
|
+
decisions, not drift.
|
|
13
|
+
- TS-first surfaces (browser-native capture etc.) get a row in reverse: Python owes the port.
|
|
14
|
+
|
|
15
|
+
Canonical port decisions (Zod, camelCase, fixture corpus, browser API keys): see
|
|
16
|
+
`docs/plans/typescript-sdk-port.md`.
|
|
17
|
+
|
|
18
|
+
## Open gaps (Python has it, TS doesn't)
|
|
19
|
+
|
|
20
|
+
None. Every Python-SDK surface is either ported (below) or a documented "won't port".
|
|
21
|
+
|
|
22
|
+
> **Dated correction (2026-07-26).** This section read "None" while `library.add` was in fact
|
|
23
|
+
> missing — TS had only `assets.upload` (files), so the **fileless** *description-as-content* asset
|
|
24
|
+
> Python has offered since the description-as-content decision had no TS equivalent, and the guide's
|
|
25
|
+
> TS tab carried a workaround comment saying so. The gap is closed below (ergonomics pass), but the
|
|
26
|
+
> ledger was wrong for the whole window and that is worth recording: a "None" nobody re-derives is
|
|
27
|
+
> how drift hides. The rule stands — a Python surface that lands without a TS twin gets a row here in
|
|
28
|
+
> the same MR.
|
|
29
|
+
|
|
30
|
+
## Ported (was open, now in TS)
|
|
31
|
+
|
|
32
|
+
- **Ergonomics pass (breaking, 2026-07-26):** an API-shape round over the whole TS surface, driven by
|
|
33
|
+
an audit rather than a new Python surface. **`library.add({ file?, description?, modality?,
|
|
34
|
+
collection? })`** — the twin of Python's `library.add`, closing the fileless gap above (a plain form
|
|
35
|
+
body, no file part; `add()` with neither a file nor a description raises `InvalidArgumentError`).
|
|
36
|
+
New fixtures `assets/add_fileless` + `assets/add_fileless_full`, call-sited in **both** suites.
|
|
37
|
+
Everything else in the pass is TS-side shape, not new capability: `search` gained an options-only
|
|
38
|
+
overload (`search({ like })` replaces `search("", { like })`); `surveys.start` is options-only
|
|
39
|
+
(`start({ template, into })`); `chat` lost its `stream` flag to a separate `chatStream`;
|
|
40
|
+
`ChatOptions`/`AgentOptions` swapped their index signatures for an explicit `params` bag;
|
|
41
|
+
`sources.import(target, opts?)` and `sessions.event(id, type, payload?, opts?)` took their
|
|
42
|
+
load-bearing argument positional; `Session` became a bound class (see the micro-divergence note);
|
|
43
|
+
`Room` split into `Room` + `OwnedRoom`; `room.play`'s source is typed (`PlayTrack` / `PlaySource`);
|
|
44
|
+
`room.send` gained `replyTo` / `ackId`; `assetCapabilities` / `assetPlayback` became
|
|
45
|
+
`asset.capabilities` / `asset.playback` getters; `WebSocketCtor` → `webSocketCtor`; the root barrel
|
|
46
|
+
dropped its protocol internals. Wire-identical throughout — the shared fixture corpus passes
|
|
47
|
+
unchanged apart from the two deliberate new registrations. Type-level tests
|
|
48
|
+
(`tests/types.test-d.ts`, run by `pnpm test:types`) pin the soundness wins as compile errors.
|
|
49
|
+
|
|
50
|
+
- **Wave 6 (the last phase-2 bundle: surveys, direct models, chat streaming, `agent`, 2026-07-26):**
|
|
51
|
+
closes the open-gaps table. **`atrium.surveys`** (`Surveys` in `src/atrium.ts` + `src/shaping/surveys.ts`,
|
|
52
|
+
the `surveys.py` twin): `start(template?, { into, name?, returnTo? })` → a `Survey` (`token` / `url` /
|
|
53
|
+
the pinned `{name, version}` template, plus `open()` — `window.open`, throwing `InvalidArgumentError`
|
|
54
|
+
outside a browser rather than pretending); the idempotent `template(name, body, { project?, default? })`
|
|
55
|
+
ensure flow (POST → 409 → GET → `/versions` → PATCH `is_default`), whose "did it change?" test is a
|
|
56
|
+
key-order-insensitive compare of the **JSON-encoded** body (`sameJsonBody`) so a rerun never appends an
|
|
57
|
+
identical version; `templates()`; `responses(name, opts)` → `SurveyResponse[]` **and** `responsesCsv(name, opts)`
|
|
58
|
+
→ `Uint8Array`; `uploadFile(file, { project?, role?, filename? })`. Project refs pass through **verbatim**
|
|
59
|
+
(a short_id must not trigger a projects read a scoped key is denied) and template names are
|
|
60
|
+
percent-encoded in every path. **Direct models** (`src/direct.ts`): `DirectEndpoint` / `DirectRegistry`,
|
|
61
|
+
`addDirectModel(s)` / `removeDirectModel` / `listDirectModels` / `discoverModels`, plus the
|
|
62
|
+
`AtriumOptions.directModels` constructor form; a registered name shadows an Atrium model and its chat
|
|
63
|
+
goes out on the **raw `fetch`** with only that endpoint's key — the Atrium credential never reaches a
|
|
64
|
+
third-party endpoint — while `embed` and everything else stay on Atrium. **`chat(messages, { stream: true })`**
|
|
65
|
+
→ `AsyncIterable<string>` (returned synchronously, no `await` before `for await`) over the SSE
|
|
66
|
+
`[DONE]`-terminated delta wire, on the Atrium route and direct endpoints alike, reusing the room
|
|
67
|
+
transport's `sseFrames`. **`atrium.agent(prompt, { tools, maxTurns?, responseFormat? })`** with
|
|
68
|
+
explicit `ToolDescriptor`s (`src/tools.ts`) — see the micro-divergence below. `HttpMethod` gained
|
|
69
|
+
`PATCH`. Fixtures: `clients/wire-fixtures/surveys.json` (11 requests + 3 responses), call-sited in
|
|
70
|
+
**both** suites. Tests: `tests/surveys.test.ts` / `tests/direct.test.ts` / `tests/chat-stream.test.ts` /
|
|
71
|
+
`tests/agent.test.ts` (mirrors of `test_surveys.py` / `test_direct.py` / the Python `agent` cases).
|
|
72
|
+
|
|
73
|
+
- **Wave 5 (the magical-dashboard baseline: `Dashboard` + `room.trace()` + `audioTranscripts`,
|
|
74
|
+
2026-07-26):** the three surfaces a hosted `/tools/dashboard` needs from a TS host.
|
|
75
|
+
**Host-side `Dashboard`** (`src/dash.ts`, the `dash.py` twin on the **v2 tile wire**): tiles keyed
|
|
76
|
+
by `kind` (`status`/`text`/`image`/`sound`/`decision`) with `col_span`/`row_span` (1..3, validated
|
|
77
|
+
→ `InvalidArgumentError`) + `group`; first-class `decision(candidates, { picked, reasoning })`
|
|
78
|
+
(bare-label or `{label, detail?, score?}` candidates); **`components` is THE snapshot carrier**
|
|
79
|
+
(no `tiles` alias); `error(message, detail?)` broadcasts the sticky-strip `dash.error` signal
|
|
80
|
+
(`{message, detail?, at}`) and is **not** a tile; controls are `button(id, label, handler, opts)` /
|
|
81
|
+
`textInput(id, handler, opts)` (the `text` tile owns `text()`) + `onNote(handler)` — no decorator
|
|
82
|
+
form (TS takes the handler as an argument, chainable), no sidebar-sections API. Host-answers-join
|
|
83
|
+
(`presence.join` → a targeted ephemeral `dash.snapshot`), a `dash.delta` per mutation once
|
|
84
|
+
connected, `dash.action` replies through the room dispatcher. **`room.dashboard(opts)`** is the
|
|
85
|
+
recommended constructor: it publishes over the REST `/signal` POST so the dialect's "all `dash.*`
|
|
86
|
+
is ephemeral" actually holds (the WS inbound path forces `ephemeral: false` — same reason the
|
|
87
|
+
transcriber publishes over REST). `new Dashboard(roomLike, opts)` still works for a decoupled/test
|
|
88
|
+
room; `Room` gained a public `identity` getter (the snapshot's `host` fallback, Python parity).
|
|
89
|
+
**`room.trace()`** (`src/trace.ts`): the same envelope + type folding — `trace.<label>` for
|
|
90
|
+
`library.search` / `library.search_by_examples` / `library.enrich` / `chat` (client-wide, durable)
|
|
91
|
+
and `play` (room-scoped, ephemeral), `trace.state.<label>` for `recorder` / `transcriber` /
|
|
92
|
+
`offer.<name>` (ephemeral), identical summary strings + capped top-5 detail. Zero-cost when off
|
|
93
|
+
(the sink list is empty → the summarizer never runs), a failing emit never breaks the traced call,
|
|
94
|
+
a call that throws emits nothing, `stop()` unregisters from both the client and the room, and the
|
|
95
|
+
room's teardown stops every handle. **`room.audioTranscripts({model, language, partials, skipEmpty,
|
|
96
|
+
signal})`** — the raw per-snippet iterator on wave 3's shared engine, including Python's
|
|
97
|
+
per-snippet correlation on the degraded path (`transcript.raw.snippet = {sequence,
|
|
98
|
+
window_seconds}`, the clip's format taken from each frame's own MIME type), via a new
|
|
99
|
+
`TranscribeStreamOptions.fallback` seam (the twin of Python's `_transcribe_engine(fallback=…)`).
|
|
100
|
+
Tests: `tests/dash.test.ts` / `tests/trace.test.ts` / `tests/audio-transcripts.test.ts` (mirrors of
|
|
101
|
+
`test_dash.py` / `test_trace.py` / `test_audio_transcripts.py`), pinning the wire JSON a
|
|
102
|
+
`/tools/dashboard` parser reads rather than importing the tool's types.
|
|
103
|
+
- **SDK surface expansion Rounds 1–2 (introspection + read-back, 2026-07-20):** the port of
|
|
104
|
+
`docs/plans/sdk-surface-expansion.md`. **Round 1:** per-asset vectors (`atrium.assets.vectors(assetOrId,
|
|
105
|
+
{space?, matchBy?})` — public by-id GET, space name-or-id resolution, mixed `matchedBy` tolerated) +
|
|
106
|
+
the bound-object migration — **`Asset` is now a class** (data fields stay own/enumerable/JSON-safe,
|
|
107
|
+
a truly-private `#binder`; `asset.vectors()`/`.segments()`/`.index()` on the prototype, threaded in
|
|
108
|
+
at parse via a new `ClientInternal.assetBinder`; an unbound asset rejects, naming the id-form call);
|
|
109
|
+
bulk `atrium.library.vectors(ids, {space, matchBy?, strict?})`; `atrium.assets.segments()` (reuses
|
|
110
|
+
`SegmentRef`) + `.index()` (server `via` → `matchedBy`); `search({includeVectors})` (`hit.vector`,
|
|
111
|
+
`include_vectors` omitted when falsy); `compare(a, b, {breakdown})` → `CompareResult.features`; tag
|
|
112
|
+
reads `atrium.library.tags()` / `.tag(nameOrId, {includeSnapshot?})`; the Round-1.6 `Asset` fields
|
|
113
|
+
(labels/rejectedLabels/proposedLabels/autoTagged/createdAt/updatedAt/status/descriptionSource/
|
|
114
|
+
homeCollectionId/externalId/origin/sourceId). Broker guard lifted: an `embed.resolve` **asset**
|
|
115
|
+
anchor now resolves through the per-asset vectors endpoint (`brokers.ts` + `room.ts`).
|
|
116
|
+
**Round 2:** `atrium.sessions.list({project?, phase?})` / `.get(id)` (read-model `SessionInfo`, twin
|
|
117
|
+
of `resume`'s live handle — same route, different shape; `Session` handle gained the identity
|
|
118
|
+
fields) / `.events(id, {after?, limit?})` / `.export(id, format?)` / `.exportBundle(ids)`. **Node/
|
|
119
|
+
browser choice:** exports return `Uint8Array` — no `path=` file-writing (the core never touches the
|
|
120
|
+
filesystem, `files.ts`); a Node caller writes the bytes itself. **Deliberate gap kept:** surveys
|
|
121
|
+
(see Open gaps). Tests: `tests/introspection.test.ts` + `tests/readback.test.ts` (mirror the Python
|
|
122
|
+
`test_introspection.py`/`test_readback.py` contract points), brokers test updated for asset anchors.
|
|
123
|
+
- **Wave 4 (standing-service offers + capability brokers, 2026-07-18):** the third broker kind on the
|
|
124
|
+
**v2.1 armed-set wire**. `ArmedSet` state machine (`src/rooms/armed-set.ts`): explicit ∪ (all-arm −
|
|
125
|
+
exclusions-since), fresh all-arm resets exclusions, `isArmed(null)` only under all-arm, migrate on
|
|
126
|
+
same-name reconnect. `StandingSource` shared base (`src/rooms/standing-source.ts`) — armed set + cap
|
|
127
|
+
admission, the `active` roster built on the **armed set** (immediate-on-arm; all-arm enumerates every
|
|
128
|
+
connected `capture-source`; v1 unsourced → `{source: null}`), ephemeral `<service>.available`
|
|
129
|
+
broadcast (deduped, over the REST `/signal` POST), an **own point-in-time roster** folded in loop
|
|
130
|
+
order (seeded from the ready snapshot), presence/migration, one grace sweeper (not a timer per
|
|
131
|
+
departure), the scoped `start`/`stopRequest` shells. `room.offerTranscription({model?, language?, maxSources?})`
|
|
132
|
+
→ `OfferedTranscription` (`src/rooms/offer-transcription.ts`): per-armed-source engines over wave 3's
|
|
133
|
+
`transcribeStream` (one bounded drop-oldest queue per source), publishing `source`/`source_name`-attributed
|
|
134
|
+
`transcript.*`. `room.offerAudioCapture({maxSources?})` → `OfferedCapture` (`src/rooms/offer-capture.ts`):
|
|
135
|
+
**session-less** — binds the FIRST active recorder's session via wave 2's `activeRecorders()` /
|
|
136
|
+
`onRecordersChanged` seam (disengage when it dies; one-recorder rule warns, no double-write), writes
|
|
137
|
+
via the chunked recording API (`POST …/recordings` → `PUT …/chunks` → `POST …/finalize`), collision-safe
|
|
138
|
+
keys (name-else-cid; same-name grace reconnect resumes the file; simultaneous same-name → full-cid-suffixed
|
|
139
|
+
files), grace finalize via the single sweeper, create-failure circuit breaker, 401/403 ends the bridge.
|
|
140
|
+
`offerService` wires sender-scoped `<service>.start`/`.stop`/`.status` with `{ok}`/`{error}` replies.
|
|
141
|
+
`room.offerEmbeds({spaces?, maxAnchors?})` / `room.offerChat({model?, maxTokens?})` brokers
|
|
142
|
+
(`src/rooms/brokers.ts` validators): the loginless-Unity requester pattern — requester picks the embed
|
|
143
|
+
space (per-anchor errors don't fail the batch; text embedded on the host's credential + cached), broker
|
|
144
|
+
pins the chat model. All four announce their discoverable role, merged into the presence announce
|
|
145
|
+
(`mergedAnnounce`), registered **before** `connect()`; stopped on room close. `ShapedRequest` gained a
|
|
146
|
+
raw-`content`/`contentType` channel for the binary chunk PUT.
|
|
147
|
+
- **Wave 3 (live transcription, 2026-07-18):** `Atrium.transcribeLive(chunks, opts)` — the streaming
|
|
148
|
+
upgrade over the shared engine (`src/transcribe.ts`): a thin WS client to `WS
|
|
149
|
+
/v1/audio/transcriptions/stream` (`src/transcribe-stream.ts`, `TranscriptionStream`), container-clip
|
|
150
|
+
**or** `pcm16 + sampleRate` input, `partials` opt-in (finals-only default), and the **connect-time-only**
|
|
151
|
+
degradation ladder to per-clip batch POSTs (a mid-session drop / clean-close-without-`done` / 4401
|
|
152
|
+
**raises** — `AtriumError` / `AuthError` — never a silent success; PCM is re-windowed into ~5s WAV
|
|
153
|
+
clips for the fallback). `Transcript.final` added to the transcript model (`true` for every batch
|
|
154
|
+
transcript + finalized sentence, `false` for a live partial). `room.transcribe({model?, language?})`
|
|
155
|
+
→ `Transcriber` (`src/rooms/transcriber.ts`) — the in-room broker: consumes the room's snippet stream
|
|
156
|
+
(wave-1 `snippetStream`) through the same engine and publishes `transcript.partial` (ephemeral) /
|
|
157
|
+
`transcript.final` (`{text, start, end, seq}`) as `source: "transcriber"`, so **wave 2's recorder
|
|
158
|
+
records the finals for free**. Handle mirrors Python's `_AsyncTranscriber`: `stop()` / `alive` /
|
|
159
|
+
`error`, stopped when the room closes, `await using`. Needs the authed client slice
|
|
160
|
+
(`RoomAudioClient.transcribeStream`); a loginless `joinRoom` handle throws.
|
|
161
|
+
- **Wave 2 (durable & reactive surfaces, 2026-07-18):** `room.record(into)` → `Recorder`
|
|
162
|
+
(`src/rooms/recorder.ts`) — the single durable binding, over an SSE-**observe** `RoomEventStream`
|
|
163
|
+
(no presence connection): skips `ephemeral` + `presence.*` + the `room.code_rotated` advance-signal,
|
|
164
|
+
stamps `via` = `{room_id, room_name, from, to}`, reconnects on a drop, stops cleanly on a rotation
|
|
165
|
+
(`stoppedReason`) or terminal room-gone/bad-code (`stoppedReason` **and** `.error`); handle is
|
|
166
|
+
`stop()` + `alive` / `error` / `stoppedReason` + `await using`. The **recorder-registration seam** —
|
|
167
|
+
`room.activeRecorders()` (membership + liveness) and `room.onRecordersChanged(listener)` (mirror of
|
|
168
|
+
Python's `_notify_recorders_changed`) — is ported now so wave 4's `offerAudioCapture` binds to the
|
|
169
|
+
active recorder. `room.audioVectors({space?, model?, relay?})` — the zero-args embedding-vector
|
|
170
|
+
consumer (dedicated snippet-consumer socket, embeds each ~10s window against the resolved default
|
|
171
|
+
audio space/model, `relay` re-broadcasts each vector as an ephemeral `audio.vector`). `room.audioScores(anchors, {space?, model?, topN?, relay?})` — per-anchor `library.compare` fusion
|
|
172
|
+
(few-shot list anchors via `toAnchorMembers`/`by.*`, top-`N` mean, mix normalized to sum 1, concept
|
|
173
|
+
anchors preflighted), wired to the existing `Decider`. `audioVectors`/`audioScores` need the authed
|
|
174
|
+
client (injected as `RoomDeps.client`; a loginless `joinRoom` handle throws). `Space.model` now
|
|
175
|
+
surfaced. The recorder matches Python's write semantics exactly: each `session.event` POST is
|
|
176
|
+
awaited **inline** in the observe-stream read loop (backpressure — one write in flight, no unbounded
|
|
177
|
+
queue), a write refused with status < 500 (≠ 429) is **terminal** (`.error` set, the recorder-changed
|
|
178
|
+
seam fires so a bound capture offer disengages), and a transient 5xx/429/network write blip drops +
|
|
179
|
+
reconnects the stream (the gap is lost, capture continues).
|
|
180
|
+
- **Wave 1 (consumers & transport, 2026-07-18):** SSE receive + POST-send room transport with the
|
|
181
|
+
WS→SSE fallback ladder (`transport: "auto" | "ws" | "sse"`, `src/rooms/sse.ts` — Python's
|
|
182
|
+
`_RoomDispatcher`/`_events` parity: code-auth, reconnect/backoff, `room.code_rotated` in-band
|
|
183
|
+
terminal, 403/404 terminal; **signals-only**, binary stays WS-only, and rooms have no replay so no
|
|
184
|
+
`Last-Event-ID` cursor); `audio.snippet` **v2 decode** (`decodeAudioSnippetFrame`, source-attributed,
|
|
185
|
+
v1 still decodes); `room.audioClips()` raw-clip async iterator (source/sourceName from the
|
|
186
|
+
subscribe-time roster snapshot); `room.participants()` / `room.has()` typed `Participant` roster
|
|
187
|
+
(merged `role`+`roles`, `connectedAt` as `Date`) over one short observe stream; `dashboardUrl()` /
|
|
188
|
+
`openDashboard()` / `captureUrl()` / `openCapture()` URL helpers.
|
|
189
|
+
|
|
190
|
+
## Public surface notes (pre-release)
|
|
191
|
+
|
|
192
|
+
- The default `hci-atrium` barrel exports only the two roster *types* an app touches: the parsed
|
|
193
|
+
`Participant` (merged roles / `name` / `connectedAt`, from `rooms/participant.ts`) and the raw
|
|
194
|
+
transport item `RosterEntry` (from `rooms/transport.ts`). Internal helpers — `mergeRoles`,
|
|
195
|
+
`parseParticipant`, `participantMatches`, `readObserveRoster`, `parseAnchorDescriptor` /
|
|
196
|
+
`AnchorDescriptor` — are **no longer re-exported** from the top-level entry (they stay importable
|
|
197
|
+
internally via `rooms/index`). Also on the record: the `Participant` export was re-pointed to the
|
|
198
|
+
parsed view and the raw roster item renamed to `RosterEntry`. Pre-release, no consumers.
|
|
199
|
+
- **Barrel trim, 2026-07-26 (ergonomics pass).** The root `hci-atrium` entry now exports the two room
|
|
200
|
+
handles (`Room`, `OwnedRoom`), the values their methods hand back (`Recorder`, `Transcriber`,
|
|
201
|
+
`OfferedCapture`, `OfferedTranscription`, `RoomTrace`, `Dashboard`, `Decider`, …), the models, the
|
|
202
|
+
errors, and the option/event types an app names. **Demoted** (still importable from their modules,
|
|
203
|
+
just off the barrel): the audio/snippet frame codec and its `AUDIO_SNIPPET_MAGIC` / `*_VERSION`
|
|
204
|
+
constants, every role string (`AUDIO_ROLE`, `*_BROKER_ROLE`, `CAPTURE_*`, `TRANSCRIBER_SOURCE`, …),
|
|
205
|
+
the transport internals (`RoomEventStream`, `RoomTransport`, `RoomSignal`), the offers' machinery
|
|
206
|
+
(`ArmedSet`, `StandingSource`, `OFFER_MAX_SOURCES`), the dependency-injection bags (`RoomDeps`,
|
|
207
|
+
`OwnedRoomDeps`, `RoomAudioClient`, `OfferDeps`, `CaptureOfferDeps`, `TranscriberDeps`, …),
|
|
208
|
+
`validateChatMessages`, and the trace dialect helpers (`traceEnvelope`, `traceCallType`,
|
|
209
|
+
`traceStateType`, `TRACE_STATE`, `isTraceEphemeral`). These describe the wire, not the app; the
|
|
210
|
+
hosted tools import none of them.
|
|
211
|
+
|
|
212
|
+
**Demoted is not removed.** 0.3.x shipped these *on the barrel*, so this is a breaking change for
|
|
213
|
+
anyone who named one — the package is on npm and its download count is not zero. The migration path
|
|
214
|
+
is the `./internal/*` subpath export, which mirrors the source tree:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
import { decodeAudioFrame } from "hci-atrium/internal/rooms/codec.js" // extension optional
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
It carries **no semver guarantee** — internals move without a major bump, by definition. It exists
|
|
221
|
+
so a 0.3 consumer has a one-line edit instead of a fork, not as a second public API.
|
|
222
|
+
|
|
223
|
+
## Won't port (by design)
|
|
224
|
+
|
|
225
|
+
| Surface | Reason |
|
|
226
|
+
| --- | --- |
|
|
227
|
+
| CLI (`atrium …`) | Documented parity exception — except `atrium guide`, which the npm package ships to print GUIDE.md. |
|
|
228
|
+
| Home Assistant bridge | Documented parity exception. |
|
|
229
|
+
| Sync client surface | JS is async-native; single-surface SDK is the idiom. |
|
|
230
|
+
| Raw-PCM mic input à la `sounddevice` | Browser equivalent is `MediaRecorder`/AudioWorklet clips — same `transcribeLive` contract, different capture idiom. |
|
|
231
|
+
|
|
232
|
+
## Known micro-divergences (documented, acceptable)
|
|
233
|
+
|
|
234
|
+
- **Agent tools are descriptors, not decorated functions.** Python's `@client.tool` derives a tool's
|
|
235
|
+
OpenAI schema from the function's signature + docstring; TypeScript erases types and has no
|
|
236
|
+
docstrings, so a tool is an explicit `{ name, description, parameters, execute }` descriptor.
|
|
237
|
+
`parameters` is plain JSON Schema, or `{ jsonSchema, validate? }` where `validate` is any
|
|
238
|
+
[Standard Schema](https://standardschema.dev) object (Zod ≥3.24, Valibot, ArkType — the `~standard`
|
|
239
|
+
property) used to parse the model's arguments before `execute` runs. A **bare** schema object is
|
|
240
|
+
rejected with a message naming the fix: deriving JSON Schema from it would mean taking the library
|
|
241
|
+
as a runtime dependency, and the package is zero-dependency by design (Zod stays an *optional* peer,
|
|
242
|
+
loaded lazily only by structured output). One more small deviation inside the loop: a tool's return
|
|
243
|
+
value is JSON-encoded when it's an object (JS's `String({})` is the useless `"[object Object]"`,
|
|
244
|
+
where Python's `str(dict)` is readable).
|
|
245
|
+
- **No `DirectTransports`.** Python caches one `httpx.Client` per direct endpoint and closes them with
|
|
246
|
+
the client; `fetch` is stateless, so TS has nothing to pool or close. The per-endpoint timeout
|
|
247
|
+
becomes an `AbortSignal.timeout` folded into each call's signal — and it is in **milliseconds**
|
|
248
|
+
(the JS unit), not Python's seconds.
|
|
249
|
+
- **`surveys.responsesCsv` is its own method**, where Python has `responses(format="rows"|"csv")`.
|
|
250
|
+
The two return types have nothing in common (parsed rows vs raw bytes), so a union return would be
|
|
251
|
+
a type-level lie the caller has to narrow — the same call the `sessions.export` port already made.
|
|
252
|
+
Also, as there: no `path=` file-writing, since the core never touches the filesystem.
|
|
253
|
+
- `surveys.start({ into })` takes a `Session` handle **or** a bare session id — unchanged. What
|
|
254
|
+
changed (2026-07-26, **overturning** the note that used to live here): TS's `Session` is no longer
|
|
255
|
+
an inert read model. It is a class with a truly-private `#binder`, exactly like `Asset`, carrying
|
|
256
|
+
`session.event(type, payload?, opts?)` bound to the client that produced it — the twin of Python's
|
|
257
|
+
`Session.event`. Python's instance offers *only* `event`, so that is all TS attaches; the id-based
|
|
258
|
+
`sessions.*` namespace (`sessions.event(id, …)`, `sessions.export(id)`, …) stays the complete
|
|
259
|
+
surface and is what an unbound session's error message names. A `Session` built by hand is now a
|
|
260
|
+
`parseSession({...})` call rather than an object literal — a class with a private field cannot be
|
|
261
|
+
satisfied structurally.
|
|
262
|
+
- **`chatStream` is a separate method**, where Python has `chat(..., stream=True)`. A flag that swaps
|
|
263
|
+
the return type between `Promise<ChatReply>` and `AsyncIterable<string>` forces every call site to
|
|
264
|
+
narrow a union it already knows the answer to, and Python's dynamic `stream=True` has no such cost.
|
|
265
|
+
The behaviour is identical either way (the iterable is returned **synchronously** — nothing to
|
|
266
|
+
`await` before the `for await`, same as Python's generator), so this is a type-soundness divergence
|
|
267
|
+
only. It also retires a runtime error: "stream: true can't be combined with a schema
|
|
268
|
+
`responseFormat`" is now unrepresentable, since structured output lives on `chat` alone.
|
|
269
|
+
- A **streamed** chat is traced at hand-off (one `trace.chat`, no reply preview, ~0 duration) —
|
|
270
|
+
mirroring Python, whose decorator emits when the generator is returned, before anything is consumed.
|
|
271
|
+
- **`Room` / `OwnedRoom` split.** Python's `Room` carries its client, so its host-side verbs are
|
|
272
|
+
always callable and the loginless `join_room` handle is a different class already. TS used to hand
|
|
273
|
+
back one `Room` for both and refuse at runtime (`requireClient` → "no account credential"). The
|
|
274
|
+
~14 credential-bound members now live on `OwnedRoom extends Room`, which `client.rooms.open/get/list`
|
|
275
|
+
return; `joinRoom(code)` **and** `client.rooms.join(code)` return the participant `Room`. Same
|
|
276
|
+
capability boundary, moved from the runtime to the type — `room.record(...)` on a code-joined handle
|
|
277
|
+
is a compile error, not a thrown one. `rooms.join` stays a plain `Room` deliberately: it is built
|
|
278
|
+
without the REST caller and capability slice (a code grants live transport, not the account), so
|
|
279
|
+
typing it `OwnedRoom` would advertise members that throw.
|
|
280
|
+
- **`chat` / `agent` passthrough is an explicit `params` bag**, where Python takes `**kwargs`. TS's
|
|
281
|
+
equivalent was an index signature (`[key: string]: unknown`) on the options interface, which made
|
|
282
|
+
every misspelled SDK option — `responseFormatt`, `modle` — a silently-forwarded OpenAI param. The
|
|
283
|
+
bag keeps the capability (`params: { temperature: 0.3 }`) and gets the typo checking back; SDK-owned
|
|
284
|
+
keys are applied last and win a collision. Same wire.
|
|
285
|
+
- **`room.send` carries `to` + `replyTo` only**, where Python's `Room.send` also takes `ephemeral`.
|
|
286
|
+
Python sends over the REST `/v1/rooms/{id}/signal` POST, which carries the flag; TS sends over the
|
|
287
|
+
room WebSocket, whose inbound path forces `ephemeral: false` server-side. An `ephemeral=` parameter
|
|
288
|
+
on the TS `send` would be a field the transport silently drops, so it isn't offered — the surfaces
|
|
289
|
+
that need ephemerality (`room.dashboard()`, `room.trace()`, `room.transcribe()`, the standing
|
|
290
|
+
offers, `audioVectors({relay: true})`) all publish through the REST POST instead, which is why they
|
|
291
|
+
are `OwnedRoom` members. `ackId` is likewise absent: a reply needs a registered pending entry, and
|
|
292
|
+
only `room.request(...)` creates one.
|
|
293
|
+
- **Base-URL resolution is one helper** (`resolveBaseUrl`, `src/env.ts`) shared by the client,
|
|
294
|
+
`joinRoom`, and the React `useRoomConnection` hook: explicit `baseUrl` → `ATRIUM_BASE_URL` →
|
|
295
|
+
(hook only) the page's own origin → the deployed default. The hook's origin rung is the one
|
|
296
|
+
difference, deliberate — it is browser-only, and a hosted tool page served *by* an instance should
|
|
297
|
+
talk to that instance. **Behavior change (2026-07-26):** the hook previously hardcoded
|
|
298
|
+
`location.origin`, so a tool pointed elsewhere at build time via `ATRIUM_BASE_URL` silently ignored
|
|
299
|
+
it; env is now consulted first. A page that relied on origin-wins while also setting the env var
|
|
300
|
+
resolves differently.
|
|
301
|
+
- `room.play` for byte-backed assets: TS sends compact `{asset_id}`, Python sends a playback
|
|
302
|
+
intent. Both shapes work at the Player.
|
|
303
|
+
- **Player dialect arguments are typed in both spellings.** `room.play(track)` and
|
|
304
|
+
`room.configureLayers(layers)` accept the SDK's camelCase (`PlayTrack` / `LayerDeclaration`) *or*
|
|
305
|
+
the dialect's own snake_case (`WireTrack` / `LayerSpec`, from the `hci-atrium/player` subpath),
|
|
306
|
+
translating the former at the shaping seam. Python takes the wire dict directly and has no second
|
|
307
|
+
spelling. A key belonging to neither is a compile error, which is the point: the runtime always
|
|
308
|
+
accepted a hand-rolled wire descriptor, and before this the type didn't. The read-back
|
|
309
|
+
(`queryLayers` / `watchLayers`) still hands back the raw wire snapshot, snake_case — it is the
|
|
310
|
+
Player's payload, not an SDK model.
|
|
311
|
+
- **One `PlaybackIntent`.** The `{source, ref, mode?}` intent is defined once in `models.ts` and
|
|
312
|
+
re-exported by `hci-atrium/player`'s `play-command`, so the type an app reads off `asset.playback`
|
|
313
|
+
is the exact type the Player runtime dispatches on. `ref` is therefore nullable there too (a
|
|
314
|
+
description-only asset has no target): `routePlayIntent` reads a missing ref as "nothing to play",
|
|
315
|
+
and narrows `mode` to the `"shuffle"` the Spotify resolver understands.
|
|
316
|
+
- `room.play`'s `asset` track key (landed both sides 2026-07-26, together with the `gain` per-call
|
|
317
|
+
option: `play({asset: hit, gain: 2})` / `play(source, {gain: 2})`, per-track gain winning): what
|
|
318
|
+
counts as "an asset under `asset`" is decided differently. Python expands anything that isn't a
|
|
319
|
+
`dict` (an `Asset`/`SearchHit`/id string — anything else raises the usual `TypeError`) and leaves a
|
|
320
|
+
`dict` alone, since that's the wire's inline-asset descriptor; TS has no dict/object split, so it
|
|
321
|
+
expands only a value that *looks* like a parsed `Asset` (`isAssetLike`), a `SearchHit`
|
|
322
|
+
(`score` + `matchedBy`), or a string, and passes anything else through untouched. Same rule for
|
|
323
|
+
every SDK-object call, same wire; only a hand-rolled oddity under `asset` behaves differently
|
|
324
|
+
(Python throws where TS ships it as-is) — the same heuristic trade-off as the `isAssetLike` note above.
|
|
325
|
+
- Live-transcription WS auth: Python rides an `Authorization: Bearer` header on the handshake; the TS
|
|
326
|
+
client can't (browsers forbid custom WS headers), so it carries the credential in-band as the
|
|
327
|
+
`start` frame's `api_key` field — the backend's browser-safe first-frame path (accept → read
|
|
328
|
+
`start` → pop `api_key` → validate). Same credential, different carrier; and unlike the legacy
|
|
329
|
+
`?api_key=` query the backend still tolerates, it never rides the URL into proxy/access logs. A
|
|
330
|
+
rejected key closes `4401` after the socket opened, still surfaced as `AuthError` (never a silent
|
|
331
|
+
degrade to the per-clip batch fallback).
|
|
332
|
+
- `room.dashboard()` and `room.trace()` publish over the REST `/v1/rooms/{id}/signal` POST for the
|
|
333
|
+
same reason `room.transcribe()` does (below): the WS inbound path forces `ephemeral: false`, and
|
|
334
|
+
both dialects are ephemerality-sensitive (all `dash.*` is ephemeral; a trace is durable *unless* it
|
|
335
|
+
mirrors something already durable). Both therefore need an **owned** room — a loginless `joinRoom`
|
|
336
|
+
handle throws, where Python's `Dashboard(room)`/`room.trace()` ride its REST `send` unconditionally.
|
|
337
|
+
A bare `new Dashboard(roomLike)` (the decoupled/test path) still uses the room-like's own `send`.
|
|
338
|
+
- Trace re-entrancy: Python needs a `contextvars` guard because its structured-output `chat` recurses
|
|
339
|
+
through the *traced* `chat`. The TS client's structured path calls the **untraced** private
|
|
340
|
+
`chatOnce` instead, so one logical call emits exactly one `trace.chat` with no task-local state.
|
|
341
|
+
- `room.audioTranscripts()` teardown: a consumer's `break` can't cancel the engine's pending feeder
|
|
342
|
+
`await` the way Python cancels its feeder task, so the iterator holds its **own** `AbortController`
|
|
343
|
+
(chained to an optional `signal`), aborts it in a `finally` **before** winding the engine down —
|
|
344
|
+
closing the snippet socket is what unblocks the feeder — and pulls the engine by hand rather than
|
|
345
|
+
`yield*` (which would forward `return()` into a still-parked engine and deadlock). Same documented
|
|
346
|
+
limitation family as the transcription teardown note below.
|
|
347
|
+
- `room.transcribe()` publishes its `transcript.*` signals over the REST `/v1/rooms/{id}/signal` POST
|
|
348
|
+
(like Python's `room.send`), **not** the room WebSocket — because the WS inbound path forces
|
|
349
|
+
`ephemeral: false`, and the recorder's finals-vs-partials split needs the partial's `ephemeral: true`
|
|
350
|
+
flag to survive on the wire. So the broker's ephemeral hint is honored identically to Python's.
|
|
351
|
+
- Transcription teardown: JS can't hard-cancel a pending `await` the way Python cancels the feeder
|
|
352
|
+
task, so `stop()` aborts via an `AbortSignal` (closing the snippet socket + the stream WS) and the
|
|
353
|
+
feeder stops at its next resumption point / on the input iterator's `return()`. A well-behaved input
|
|
354
|
+
ends promptly; a pathological input blocked forever on its own `await` is the one case that can't be
|
|
355
|
+
force-unblocked (documented limitation, not reachable from the room snippet path, which ends on
|
|
356
|
+
socket close).
|
|
357
|
+
- Offer source-name resolution: Python's `RoomWebSocket.frames(on_presence=…)` folds presence into a
|
|
358
|
+
roster on the *same* loop that yields binary frames, so each frame resolves its source against the
|
|
359
|
+
roster as it was when that frame was processed. The TS transport folds its live roster synchronously
|
|
360
|
+
on receipt (decoupled from the offer's async consumption), so a burst of presence + binary would race
|
|
361
|
+
the roster ahead. The offer therefore keeps its **own** point-in-time roster (`StandingSource` — name
|
|
362
|
+
+ roles), seeded from the ready snapshot and folded in loop order, and resolves `source_name` /
|
|
363
|
+
all-arm enumeration off *that*, not the transport's live roster. Same observable behaviour, folded on
|
|
364
|
+
the offer's clock.
|
|
365
|
+
- Offer engine teardown: a standing transcription offer holds a **per-source** `AbortController` (not
|
|
366
|
+
one shared signal). A per-source `transcription.stop` / a source's `presence.leave` — and the whole
|
|
367
|
+
`stop()` — abort that source's signal AND drop its buffered clips (`ClipQueue.clear()` + a `null`
|
|
368
|
+
end-sentinel), matching Python's `engine.cancel()`: buffered clips are discarded and nothing is
|
|
369
|
+
published past the disarm boundary (consent-relevant — no captions after a source stops/leaves). The
|
|
370
|
+
per-source `stopSource` runs under the offer's `Mutex`, like capture's.
|
|
371
|
+
- The offer-capture chunked recording wire (`POST …/recordings` / `PUT …/chunks` / `POST …/finalize`)
|
|
372
|
+
is pinned by byte-exact assertions in `tests/offers.test.ts` (paths, `{source_name, content_type}`
|
|
373
|
+
body, chunk `Content-Type` + bytes) — mirroring Python's `test_offers.py` — rather than added to the
|
|
374
|
+
shared `wire-fixtures` golden corpus, which has no binary-body channel and whose Python side builds
|
|
375
|
+
these calls inline (no shaping-builder call-site to register). Same wire, pinned in both suites.
|
|
376
|
+
|
|
377
|
+
## TS-first (Python owes the port)
|
|
378
|
+
|
|
379
|
+
| Surface | Notes / porting trigger |
|
|
380
|
+
| --- | --- |
|
|
381
|
+
| `room.onEnd(cb)` — typed terminal-end hook | Fans the transport's already-classified terminal reason (`RoomEndReason`: `code-rotated` / `bad-code` / `room-gone`) out to a public listener, **exactly once** per handle, for a connect-time reject and a mid-session end alike, on the WS and SSE transports both. Returns an unsubscribe; a deliberate `close()` is not an end and never fires it. Added so a driver (the observation dashboard) maps a typed reason to its own UI state instead of string-matching a reject message — and so a mid-session `room-gone` (the room deleted out from under a live consumer) is caught rather than leaving the UI stuck on "live". Python's `Room` / `AsyncRoom` classify the same terminal reasons internally (e.g. the recorder's `stopped_reason`) but expose no public `on_end`. Trigger: the next Python-host consumer that needs the typed end; port as `room.on_end(callback)`. |
|
|
382
|
+
| `atrium.data` — the participant-token tier under a survey (`GET /v1/data/resolve` · `GET`/`PUT /v1/data/content` · `POST /v1/data/submit`: read/write **one** data object with only its `atd_…` capability, no account) | The hosted survey tool speaks this tier over plain `fetch` today; nothing in either SDK wraps it. It belongs in TS **first**: the consumer is a browser page holding a per-object token, which is exactly the loginless shape TS already serves (`joinRoom`). Trigger: the `tools/` survey-runner migration onto the SDK — the same trigger the surveys row carried while it was open, and it was **wrong there**: `surveys.*` is the *researcher-side* surface (an API key, a project), which the SDK now has, and it never depended on that migration. Port to Python afterwards only if a Python host ever needs to fill a survey out. |
|
|
383
|
+
| `hci-atrium/player` — audio-layers engine + `play` / `layer.*` dialect helpers | **TS-only by design — no port owed.** A browser WebAudio surface (named, independently-mixed layers with crossfades, reverb/echo/distance effects, and 3D positioning) behind the hosted Player tool, so SoundMuse 3 and other embedders can drive it. Python hosts drive the Player *over the room dialect*; they don't run the engine, so no Python equivalent is planned. See `docs/reference/audio-layers-dialect.md`. |
|
|
384
|
+
|
|
385
|
+
*(browser mic-capture helpers for the recorder tool may also land here first.)*
|
package/README.md
CHANGED
|
@@ -18,6 +18,27 @@ pnpm add hci-atrium
|
|
|
18
18
|
|
|
19
19
|
Or `npm install hci-atrium`.
|
|
20
20
|
|
|
21
|
+
### For coding agents
|
|
22
|
+
|
|
23
|
+
`pnpm exec atrium guide` prints the complete, task-oriented SDK guide —
|
|
24
|
+
pipe it into your agent's context (`pnpm exec atrium guide > atrium-guide.md`, or just tell the
|
|
25
|
+
agent to run it). Reading `node_modules/hci-atrium/GUIDE.md` directly works too. The deployed
|
|
26
|
+
instance serves the same docs at `/llms.txt` for agents that browse.
|
|
27
|
+
|
|
28
|
+
To make it automatic, add these lines to your project's `CLAUDE.md` / `AGENTS.md`:
|
|
29
|
+
|
|
30
|
+
```markdown
|
|
31
|
+
This project uses the hci-atrium SDK. Before writing Atrium code, run
|
|
32
|
+
`atrium guide` and follow it — it is the authoritative API reference
|
|
33
|
+
(`uv run atrium guide` in Python projects, `pnpm exec atrium guide` in
|
|
34
|
+
TypeScript projects).
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The guide is written against the Python SDK; its "Reading this from TypeScript" section covers the
|
|
38
|
+
mechanical mapping, and [`PARITY.md`](PARITY.md) tracks the (few) deliberate gaps. `atrium guide` is
|
|
39
|
+
the *only* subcommand this package ships — the full CLI (`login`, `whoami`, `search`, …) comes with
|
|
40
|
+
the Python package.
|
|
41
|
+
|
|
21
42
|
## Register an app
|
|
22
43
|
|
|
23
44
|
An app is registered on an Atrium **project**, next to its API keys (Capabilities → Apps). You
|
|
@@ -64,10 +85,12 @@ component that consumes a [room](#live-rooms) by connect code — loginless, so
|
|
|
64
85
|
and no sign-in. It joins, classifies failures, and tears the handle down on unmount, returning
|
|
65
86
|
`{ status, room }`: `status` is `"connecting"` → `"connected"`, `"unreachable"` for a join/connect
|
|
66
87
|
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
|
|
68
|
-
`onRoom` — it runs the moment the handle exists,
|
|
69
|
-
isn't missed — and return a cleanup from it if
|
|
70
|
-
`"viewer:web"`); `baseUrl` defaults to
|
|
88
|
+
participant `Room` handle (loginless — no host-side surface), `null` before it exists and again after
|
|
89
|
+
a terminal end. Register signal handlers in `onRoom` — it runs the moment the handle exists,
|
|
90
|
+
**before** `connect()`, so a replayed durable signal isn't missed — and return a cleanup from it if
|
|
91
|
+
you need one. `identity` is the presence name (e.g. `"viewer:web"`); `baseUrl` defaults to
|
|
92
|
+
`ATRIUM_BASE_URL`, then the page's own origin, then the deployed instance — the same ladder the
|
|
93
|
+
`Atrium` client walks.
|
|
71
94
|
|
|
72
95
|
```tsx
|
|
73
96
|
import { useState } from "react"
|
|
@@ -130,16 +153,18 @@ apps).
|
|
|
130
153
|
|
|
131
154
|
| Member | Description |
|
|
132
155
|
| --- | --- |
|
|
133
|
-
| `library.search(query
|
|
156
|
+
| `library.search(query, opts?)` / `library.search(opts)` | Semantic search — text, `like` (an asset/hit/id), `image`/`audio`/`video`, or `vector`. Two forms, no filler positional: pass the text first, or go options-only when there is none. `matchBy` (weighted map), tag facets, and the long tail ride `filters`. |
|
|
134
157
|
| `library.searchByExamples(positives, opts?)` | Mood-board search over a set of example assets (`method` defaults to `"cav"`). Seeds are `Asset` or `by.asset(id)`. |
|
|
135
158
|
| `library.compare(a, b, opts?)` | Similarity of two operands (`Asset` / text / `by.*`), raw + calibrated. `opts.space` forces the embedding space. |
|
|
159
|
+
| `library.add(opts)` | The front door into the library: `{ file }` uploads, `{ description }` alone creates a **fileless** asset whose description *is* the content. |
|
|
136
160
|
| `library.enrich(query, opts)` | Pull external candidates into the library; `opts.preview` is a dry-run. Returns the raw payload. |
|
|
137
161
|
| `assets.list(opts?)` / `assets.get(id, opts?)` / `assets.upload(file, opts?)` | Browse, fetch, and upload assets. `file` is a `Blob`/`File` or a URL string. |
|
|
138
|
-
| `sources.list()` / `sources.search(query, opts?)` / `sources.import(opts)` | External-provider gateway — read-only fan-out search, then import a `Candidate
|
|
139
|
-
| `sessions.start(opts?)` / `sessions.resume(id)` / `sessions.event(id, type, opts?)` | Durable, append-only session logs. |
|
|
140
|
-
| `rooms.open(opts?)` / `rooms.get(id)` / `rooms.list()` / `rooms.join(code)` | Live
|
|
141
|
-
| `surveys.start(
|
|
142
|
-
| `chat(messages, opts?)` | Chat completion. Pass `responseFormat` a **Zod** schema for validated structured output (Zod is an optional peer dep)
|
|
162
|
+
| `sources.list()` / `sources.search(query, opts?)` / `sources.import(target, opts?)` | External-provider gateway — read-only fan-out search, then import a `Candidate` (or an explicit `{ sourceId, externalId }`). |
|
|
163
|
+
| `sessions.start(opts?)` / `sessions.resume(id)` / `sessions.event(id, type, payload?, opts?)` | Durable, append-only session logs. A `Session` handle also carries the bound `session.event(type, payload?)`. |
|
|
164
|
+
| `rooms.open(opts?)` / `rooms.get(id)` / `rooms.list()` / `rooms.join(code)` | Live room handles — `open`/`get`/`list` return an [`OwnedRoom`](#live-rooms), `join(code)` a participant [`Room`](#live-rooms). |
|
|
165
|
+
| `surveys.start(opts)` / `surveys.template(name, body, opts?)` / … | [Surveys](#surveys) on a session, and the project's survey templates. |
|
|
166
|
+
| `chat(messages, opts?)` | Chat completion — always a promise of one reply. Pass `responseFormat` a **Zod** schema for validated structured output (Zod is an optional peer dep). |
|
|
167
|
+
| `chatStream(messages, opts?)` | The same completion as an [`AsyncIterable<string>`](#streaming-chat) of deltas, returned synchronously. |
|
|
143
168
|
| `agent(prompt, { tools, … })` | A [tool-calling loop](#agents-and-tools) over explicit tool descriptors. |
|
|
144
169
|
| `addDirectModel(name, { baseUrl, apiKey? })` | Route one model name straight to an OpenAI-compatible endpoint ([direct models](#direct-models)) instead of Atrium. |
|
|
145
170
|
| `embed(opts)` | Embed exactly one of `text` / `image` / `audio` / `video` into a vector; `model` is required. |
|
|
@@ -152,13 +177,33 @@ apps).
|
|
|
152
177
|
|
|
153
178
|
A `Room` is pure live transport over one WebSocket — text signals *and* the ephemeral binary
|
|
154
179
|
audio-analysis stream. `room.on(type, handler)` registers a handler (its return value auto-replies to
|
|
155
|
-
a matching `room.request(...)`); `room.send(type, payload, { to })` fans out; player controls
|
|
180
|
+
a matching `room.request(...)`); `room.send(type, payload, { to, replyTo })` fans out; player controls
|
|
156
181
|
(`play` / `configureLayers` / `clearLayer` / `clearAll` / `setLayerEffects` / `queryLayers` /
|
|
157
182
|
`watchLayers` / `playerUrl`) drive the hosted Player; `room.audioSignals(cb)` decodes the
|
|
158
183
|
loudness/spectrum/beat stream. `connect()` opens the socket (every wire method calls it for you);
|
|
159
184
|
`close()` / `await using` / a passed `AbortSignal` tears it down.
|
|
160
185
|
|
|
161
|
-
|
|
186
|
+
`room.play` takes an `Asset`, a `SearchHit`, an id/URL string, a typed `PlayTrack` for per-sound
|
|
187
|
+
options, or a list of any of those — the track fields are camelCase (`assetId`, `contentUrl`,
|
|
188
|
+
`delaySeconds`, `clipStartSeconds`, `fadeInMs`, …) and the shaping layer translates them to the
|
|
189
|
+
dialect's snake_case wire spelling. The wire spelling is typed too, so a descriptor written straight
|
|
190
|
+
against [`audio-layers-dialect.md`](../../docs/reference/audio-layers-dialect.md) compiles and
|
|
191
|
+
reaches the wire untouched; a key belonging to *neither* spelling is a compile error:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
await room.play(hit, { layer: "ambient" })
|
|
195
|
+
await room.play({ asset: hit, gain: 2, loop: true, delaySeconds: 0.5 }, { layer: "sfx" })
|
|
196
|
+
await room.play({ asset_id: "a1", fade_in_ms: 250 }) // the dialect's own spelling
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`room.configureLayers` follows the same rule: `{ id, label?, volume?, crossfadeMs?, effects? }` or
|
|
200
|
+
the wire's `crossfade_ms`. (`queryLayers` / `watchLayers` hand back the Player's raw snapshot —
|
|
201
|
+
snake_case, as it arrives.)
|
|
202
|
+
|
|
203
|
+
**`Room` vs `OwnedRoom`.** `atrium.rooms.open/get/list` return an `OwnedRoom`: everything above plus
|
|
204
|
+
the host-side surfaces the account credential unlocks. `joinRoom(code)` and `atrium.rooms.join(code)`
|
|
205
|
+
return a plain `Room` — a connect code grants live transport, not the account, so those members are
|
|
206
|
+
**absent from its type** rather than throwing at runtime:
|
|
162
207
|
|
|
163
208
|
| Member | Description |
|
|
164
209
|
| --- | --- |
|
|
@@ -168,6 +213,7 @@ Host-side surfaces on an **owned** room (`atrium.rooms.open/get/join`, not the l
|
|
|
168
213
|
| `room.dashboard(opts?)` → [`Dashboard`](#observation-dashboard) | Host-side state for the hosted observation dashboard (`/tools/dashboard`). |
|
|
169
214
|
| `room.trace()` → `RoomTrace` | Mirror the SDK's own activity into the room as `trace.*` signals (default off). |
|
|
170
215
|
| `room.offerTranscription()` / `offerAudioCapture()` / `offerEmbeds()` / `offerChat()` | Standing services + capability brokers for code-joined peers. |
|
|
216
|
+
| `room.rotateCode()` / `room.delete()` | Room management (owner only). |
|
|
171
217
|
|
|
172
218
|
This SDK now covers every Python-SDK surface except the documented parity exceptions — the CLI, the
|
|
173
219
|
Home Assistant bridge, a sync surface, and raw-PCM mic input — each with its reason in
|
|
@@ -231,7 +277,10 @@ changed, and flips `default` when asked — so your templates live in your repo,
|
|
|
231
277
|
await atrium.surveys.template("post-study", postStudyBody, { default: true })
|
|
232
278
|
|
|
233
279
|
const session = await atrium.sessions.start({ participantRef: "p-07" })
|
|
234
|
-
|
|
280
|
+
await session.event("phase.start", { phase: "post-study" }) // bound to the session it came from
|
|
281
|
+
|
|
282
|
+
const survey = await atrium.surveys.start({
|
|
283
|
+
template: "post-study",
|
|
235
284
|
into: session,
|
|
236
285
|
returnTo: "https://study.example/next",
|
|
237
286
|
})
|
|
@@ -247,16 +296,26 @@ file (survey imagery) and returns the `id` a template body's `image` field refer
|
|
|
247
296
|
|
|
248
297
|
### Streaming chat
|
|
249
298
|
|
|
250
|
-
`
|
|
251
|
-
promise, so there is nothing to `await` before the loop. It works the same on a direct endpoint
|
|
252
|
-
|
|
299
|
+
`chatStream(messages, opts?)` returns an `AsyncIterable<string>` of content deltas — **not** a
|
|
300
|
+
promise, so there is nothing to `await` before the loop. It works the same on a direct endpoint. It
|
|
301
|
+
is its own method rather than a `stream: true` flag on `chat`: a flag that swaps the return type
|
|
302
|
+
between a promise and an iterable makes every call site narrow a union it already knows the answer
|
|
303
|
+
to. Structured output stays on `chat`, which always resolves one complete reply.
|
|
253
304
|
|
|
254
305
|
```ts
|
|
255
|
-
for await (const piece of atrium.
|
|
306
|
+
for await (const piece of atrium.chatStream("describe a rainy street")) {
|
|
256
307
|
process.stdout.write(piece)
|
|
257
308
|
}
|
|
258
309
|
```
|
|
259
310
|
|
|
311
|
+
Anything OpenAI-shaped that the SDK doesn't own itself rides `params` — an explicit passthrough bag
|
|
312
|
+
on both `chat` and `agent`, so a misspelled SDK option is a compile error instead of a silently
|
|
313
|
+
forwarded param:
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
await atrium.chat("hi", { model: "gpt-4o", params: { temperature: 0.3, top_p: 0.9 } })
|
|
317
|
+
```
|
|
318
|
+
|
|
260
319
|
### Agents and tools
|
|
261
320
|
|
|
262
321
|
`agent(prompt, { tools })` runs the tool-calling loop: the model may call your tools until it
|
|
@@ -386,16 +445,27 @@ through the `unhandled` hook.
|
|
|
386
445
|
|
|
387
446
|
```ts
|
|
388
447
|
import { joinRoom } from "hci-atrium"
|
|
389
|
-
import { PlayerRuntime } from "hci-atrium/player"
|
|
448
|
+
import { buildSignalEnvelope, PlayerRuntime } from "hci-atrium/player"
|
|
390
449
|
|
|
391
450
|
const baseUrl = "https://atrium.example.edu"
|
|
392
|
-
const
|
|
451
|
+
const code = "ABC12345"
|
|
452
|
+
const room = await joinRoom(code, { baseUrl })
|
|
393
453
|
const context = new AudioContext() // create it on the user's Start gesture — autoplay policy
|
|
394
454
|
|
|
395
455
|
const runtime = new PlayerRuntime({
|
|
396
456
|
context,
|
|
397
457
|
baseUrl, // asset-resolution root for the `asset_id`-only play shape
|
|
398
|
-
|
|
458
|
+
// The sink rides the REST `/signal` POST, not `room.send`: the runtime's traffic is ephemeral
|
|
459
|
+
// state chatter, and the room transport's inbound path forces `ephemeral: false` — a `room.send`
|
|
460
|
+
// sink would write a `layer.state` heartbeat into every recording session.
|
|
461
|
+
send: (type, payload, opts) => {
|
|
462
|
+
const body = buildSignalEnvelope(type, payload, room.connectionId, opts)
|
|
463
|
+
void fetch(`${baseUrl}/v1/rooms/${room.id}/signal`, {
|
|
464
|
+
method: "POST",
|
|
465
|
+
headers: { "content-type": "application/json", "x-session-code": code },
|
|
466
|
+
body: JSON.stringify(body),
|
|
467
|
+
})
|
|
468
|
+
},
|
|
399
469
|
onLog: (line) => console.log(line),
|
|
400
470
|
onSnapshot: (layers) => renderLayers(layers), // engine snapshot → your UI
|
|
401
471
|
})
|
|
@@ -415,10 +485,12 @@ await room.connect()
|
|
|
415
485
|
`LayerEngine` is the mixer; `PlayerRuntime` is the mixer *plus* the dialect. It exposes
|
|
416
486
|
`runtime.engine` for listener-side controls (per-layer / master volume, normalization, device-change
|
|
417
487
|
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.
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
488
|
+
its timers, and the master chain it built, leaving the host's `AudioContext` alone. On the `send`
|
|
489
|
+
sink: `room.send` carries `to` and `replyTo` only — enough to *route* a `layer.query` reply, but the
|
|
490
|
+
frame lands durable (the room's inbound path forces `ephemeral: false`), so `layer.state` chatter
|
|
491
|
+
would be recorded by any `room.record(...)` bridge. `buildSignalEnvelope` + the `POST
|
|
492
|
+
/v1/rooms/{id}/signal` route, as above, is the sink that keeps the dialect's ephemerality; an
|
|
493
|
+
owned-room host can equally publish through its own authenticated REST caller.
|
|
422
494
|
|
|
423
495
|
## Honest caveat: the guard is UX, not secrecy
|
|
424
496
|
|