hci-atrium 0.4.0 → 0.6.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/PARITY.md ADDED
@@ -0,0 +1,392 @@
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
+ - **Template-ensure follow-up (2026-07-27):** `template()` gained a create-only
51
+ `description?` option and a `{changed, created}` pair in its return, mirroring the
52
+ same-day Python change — the server's copy-on-write versioning means the version
53
+ number alone can't distinguish a no-op from a write, so the ensure reports it
54
+ itself. `clients/wire-fixtures/surveys.json` grew a 12th request
55
+ (`template_create_described`), call-sited in both suites.
56
+
57
+ - **Wave 6 (the last phase-2 bundle: surveys, direct models, chat streaming, `agent`, 2026-07-26):**
58
+ closes the open-gaps table. **`atrium.surveys`** (`Surveys` in `src/atrium.ts` + `src/shaping/surveys.ts`,
59
+ the `surveys.py` twin): `start(template?, { into, name?, returnTo? })` → a `Survey` (`token` / `url` /
60
+ the pinned `{name, version}` template, plus `open()` — `window.open`, throwing `InvalidArgumentError`
61
+ outside a browser rather than pretending); the idempotent `template(name, body, { project?, default? })`
62
+ ensure flow (POST → 409 → GET → `/versions` → PATCH `is_default`), whose "did it change?" test is a
63
+ key-order-insensitive compare of the **JSON-encoded** body (`sameJsonBody`) so a rerun never appends an
64
+ identical version; `templates()`; `responses(name, opts)` → `SurveyResponse[]` **and** `responsesCsv(name, opts)`
65
+ → `Uint8Array`; `uploadFile(file, { project?, role?, filename? })`. Project refs pass through **verbatim**
66
+ (a short_id must not trigger a projects read a scoped key is denied) and template names are
67
+ percent-encoded in every path. **Direct models** (`src/direct.ts`): `DirectEndpoint` / `DirectRegistry`,
68
+ `addDirectModel(s)` / `removeDirectModel` / `listDirectModels` / `discoverModels`, plus the
69
+ `AtriumOptions.directModels` constructor form; a registered name shadows an Atrium model and its chat
70
+ goes out on the **raw `fetch`** with only that endpoint's key — the Atrium credential never reaches a
71
+ third-party endpoint — while `embed` and everything else stay on Atrium. **`chat(messages, { stream: true })`**
72
+ → `AsyncIterable<string>` (returned synchronously, no `await` before `for await`) over the SSE
73
+ `[DONE]`-terminated delta wire, on the Atrium route and direct endpoints alike, reusing the room
74
+ transport's `sseFrames`. **`atrium.agent(prompt, { tools, maxTurns?, responseFormat? })`** with
75
+ explicit `ToolDescriptor`s (`src/tools.ts`) — see the micro-divergence below. `HttpMethod` gained
76
+ `PATCH`. Fixtures: `clients/wire-fixtures/surveys.json` (11 requests + 3 responses), call-sited in
77
+ **both** suites. Tests: `tests/surveys.test.ts` / `tests/direct.test.ts` / `tests/chat-stream.test.ts` /
78
+ `tests/agent.test.ts` (mirrors of `test_surveys.py` / `test_direct.py` / the Python `agent` cases).
79
+
80
+ - **Wave 5 (the magical-dashboard baseline: `Dashboard` + `room.trace()` + `audioTranscripts`,
81
+ 2026-07-26):** the three surfaces a hosted `/tools/dashboard` needs from a TS host.
82
+ **Host-side `Dashboard`** (`src/dash.ts`, the `dash.py` twin on the **v2 tile wire**): tiles keyed
83
+ by `kind` (`status`/`text`/`image`/`sound`/`decision`) with `col_span`/`row_span` (1..3, validated
84
+ → `InvalidArgumentError`) + `group`; first-class `decision(candidates, { picked, reasoning })`
85
+ (bare-label or `{label, detail?, score?}` candidates); **`components` is THE snapshot carrier**
86
+ (no `tiles` alias); `error(message, detail?)` broadcasts the sticky-strip `dash.error` signal
87
+ (`{message, detail?, at}`) and is **not** a tile; controls are `button(id, label, handler, opts)` /
88
+ `textInput(id, handler, opts)` (the `text` tile owns `text()`) + `onNote(handler)` — no decorator
89
+ form (TS takes the handler as an argument, chainable), no sidebar-sections API. Host-answers-join
90
+ (`presence.join` → a targeted ephemeral `dash.snapshot`), a `dash.delta` per mutation once
91
+ connected, `dash.action` replies through the room dispatcher. **`room.dashboard(opts)`** is the
92
+ recommended constructor: it publishes over the REST `/signal` POST so the dialect's "all `dash.*`
93
+ is ephemeral" actually holds (the WS inbound path forces `ephemeral: false` — same reason the
94
+ transcriber publishes over REST). `new Dashboard(roomLike, opts)` still works for a decoupled/test
95
+ room; `Room` gained a public `identity` getter (the snapshot's `host` fallback, Python parity).
96
+ **`room.trace()`** (`src/trace.ts`): the same envelope + type folding — `trace.<label>` for
97
+ `library.search` / `library.search_by_examples` / `library.enrich` / `chat` (client-wide, durable)
98
+ and `play` (room-scoped, ephemeral), `trace.state.<label>` for `recorder` / `transcriber` /
99
+ `offer.<name>` (ephemeral), identical summary strings + capped top-5 detail. Zero-cost when off
100
+ (the sink list is empty → the summarizer never runs), a failing emit never breaks the traced call,
101
+ a call that throws emits nothing, `stop()` unregisters from both the client and the room, and the
102
+ room's teardown stops every handle. **`room.audioTranscripts({model, language, partials, skipEmpty,
103
+ signal})`** — the raw per-snippet iterator on wave 3's shared engine, including Python's
104
+ per-snippet correlation on the degraded path (`transcript.raw.snippet = {sequence,
105
+ window_seconds}`, the clip's format taken from each frame's own MIME type), via a new
106
+ `TranscribeStreamOptions.fallback` seam (the twin of Python's `_transcribe_engine(fallback=…)`).
107
+ Tests: `tests/dash.test.ts` / `tests/trace.test.ts` / `tests/audio-transcripts.test.ts` (mirrors of
108
+ `test_dash.py` / `test_trace.py` / `test_audio_transcripts.py`), pinning the wire JSON a
109
+ `/tools/dashboard` parser reads rather than importing the tool's types.
110
+ - **SDK surface expansion Rounds 1–2 (introspection + read-back, 2026-07-20):** the port of
111
+ `docs/plans/sdk-surface-expansion.md`. **Round 1:** per-asset vectors (`atrium.assets.vectors(assetOrId,
112
+ {space?, matchBy?})` — public by-id GET, space name-or-id resolution, mixed `matchedBy` tolerated) +
113
+ the bound-object migration — **`Asset` is now a class** (data fields stay own/enumerable/JSON-safe,
114
+ a truly-private `#binder`; `asset.vectors()`/`.segments()`/`.index()` on the prototype, threaded in
115
+ at parse via a new `ClientInternal.assetBinder`; an unbound asset rejects, naming the id-form call);
116
+ bulk `atrium.library.vectors(ids, {space, matchBy?, strict?})`; `atrium.assets.segments()` (reuses
117
+ `SegmentRef`) + `.index()` (server `via` → `matchedBy`); `search({includeVectors})` (`hit.vector`,
118
+ `include_vectors` omitted when falsy); `compare(a, b, {breakdown})` → `CompareResult.features`; tag
119
+ reads `atrium.library.tags()` / `.tag(nameOrId, {includeSnapshot?})`; the Round-1.6 `Asset` fields
120
+ (labels/rejectedLabels/proposedLabels/autoTagged/createdAt/updatedAt/status/descriptionSource/
121
+ homeCollectionId/externalId/origin/sourceId). Broker guard lifted: an `embed.resolve` **asset**
122
+ anchor now resolves through the per-asset vectors endpoint (`brokers.ts` + `room.ts`).
123
+ **Round 2:** `atrium.sessions.list({project?, phase?})` / `.get(id)` (read-model `SessionInfo`, twin
124
+ of `resume`'s live handle — same route, different shape; `Session` handle gained the identity
125
+ fields) / `.events(id, {after?, limit?})` / `.export(id, format?)` / `.exportBundle(ids)`. **Node/
126
+ browser choice:** exports return `Uint8Array` — no `path=` file-writing (the core never touches the
127
+ filesystem, `files.ts`); a Node caller writes the bytes itself. **Deliberate gap kept:** surveys
128
+ (see Open gaps). Tests: `tests/introspection.test.ts` + `tests/readback.test.ts` (mirror the Python
129
+ `test_introspection.py`/`test_readback.py` contract points), brokers test updated for asset anchors.
130
+ - **Wave 4 (standing-service offers + capability brokers, 2026-07-18):** the third broker kind on the
131
+ **v2.1 armed-set wire**. `ArmedSet` state machine (`src/rooms/armed-set.ts`): explicit ∪ (all-arm −
132
+ exclusions-since), fresh all-arm resets exclusions, `isArmed(null)` only under all-arm, migrate on
133
+ same-name reconnect. `StandingSource` shared base (`src/rooms/standing-source.ts`) — armed set + cap
134
+ admission, the `active` roster built on the **armed set** (immediate-on-arm; all-arm enumerates every
135
+ connected `capture-source`; v1 unsourced → `{source: null}`), ephemeral `<service>.available`
136
+ broadcast (deduped, over the REST `/signal` POST), an **own point-in-time roster** folded in loop
137
+ order (seeded from the ready snapshot), presence/migration, one grace sweeper (not a timer per
138
+ departure), the scoped `start`/`stopRequest` shells. `room.offerTranscription({model?, language?, maxSources?})`
139
+ → `OfferedTranscription` (`src/rooms/offer-transcription.ts`): per-armed-source engines over wave 3's
140
+ `transcribeStream` (one bounded drop-oldest queue per source), publishing `source`/`source_name`-attributed
141
+ `transcript.*`. `room.offerAudioCapture({maxSources?})` → `OfferedCapture` (`src/rooms/offer-capture.ts`):
142
+ **session-less** — binds the FIRST active recorder's session via wave 2's `activeRecorders()` /
143
+ `onRecordersChanged` seam (disengage when it dies; one-recorder rule warns, no double-write), writes
144
+ via the chunked recording API (`POST …/recordings` → `PUT …/chunks` → `POST …/finalize`), collision-safe
145
+ keys (name-else-cid; same-name grace reconnect resumes the file; simultaneous same-name → full-cid-suffixed
146
+ files), grace finalize via the single sweeper, create-failure circuit breaker, 401/403 ends the bridge.
147
+ `offerService` wires sender-scoped `<service>.start`/`.stop`/`.status` with `{ok}`/`{error}` replies.
148
+ `room.offerEmbeds({spaces?, maxAnchors?})` / `room.offerChat({model?, maxTokens?})` brokers
149
+ (`src/rooms/brokers.ts` validators): the loginless-Unity requester pattern — requester picks the embed
150
+ space (per-anchor errors don't fail the batch; text embedded on the host's credential + cached), broker
151
+ pins the chat model. All four announce their discoverable role, merged into the presence announce
152
+ (`mergedAnnounce`), registered **before** `connect()`; stopped on room close. `ShapedRequest` gained a
153
+ raw-`content`/`contentType` channel for the binary chunk PUT.
154
+ - **Wave 3 (live transcription, 2026-07-18):** `Atrium.transcribeLive(chunks, opts)` — the streaming
155
+ upgrade over the shared engine (`src/transcribe.ts`): a thin WS client to `WS
156
+ /v1/audio/transcriptions/stream` (`src/transcribe-stream.ts`, `TranscriptionStream`), container-clip
157
+ **or** `pcm16 + sampleRate` input, `partials` opt-in (finals-only default), and the **connect-time-only**
158
+ degradation ladder to per-clip batch POSTs (a mid-session drop / clean-close-without-`done` / 4401
159
+ **raises** — `AtriumError` / `AuthError` — never a silent success; PCM is re-windowed into ~5s WAV
160
+ clips for the fallback). `Transcript.final` added to the transcript model (`true` for every batch
161
+ transcript + finalized sentence, `false` for a live partial). `room.transcribe({model?, language?})`
162
+ → `Transcriber` (`src/rooms/transcriber.ts`) — the in-room broker: consumes the room's snippet stream
163
+ (wave-1 `snippetStream`) through the same engine and publishes `transcript.partial` (ephemeral) /
164
+ `transcript.final` (`{text, start, end, seq}`) as `source: "transcriber"`, so **wave 2's recorder
165
+ records the finals for free**. Handle mirrors Python's `_AsyncTranscriber`: `stop()` / `alive` /
166
+ `error`, stopped when the room closes, `await using`. Needs the authed client slice
167
+ (`RoomAudioClient.transcribeStream`); a loginless `joinRoom` handle throws.
168
+ - **Wave 2 (durable & reactive surfaces, 2026-07-18):** `room.record(into)` → `Recorder`
169
+ (`src/rooms/recorder.ts`) — the single durable binding, over an SSE-**observe** `RoomEventStream`
170
+ (no presence connection): skips `ephemeral` + `presence.*` + the `room.code_rotated` advance-signal,
171
+ stamps `via` = `{room_id, room_name, from, to}`, reconnects on a drop, stops cleanly on a rotation
172
+ (`stoppedReason`) or terminal room-gone/bad-code (`stoppedReason` **and** `.error`); handle is
173
+ `stop()` + `alive` / `error` / `stoppedReason` + `await using`. The **recorder-registration seam** —
174
+ `room.activeRecorders()` (membership + liveness) and `room.onRecordersChanged(listener)` (mirror of
175
+ Python's `_notify_recorders_changed`) — is ported now so wave 4's `offerAudioCapture` binds to the
176
+ active recorder. `room.audioVectors({space?, model?, relay?})` — the zero-args embedding-vector
177
+ consumer (dedicated snippet-consumer socket, embeds each ~10s window against the resolved default
178
+ 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
179
+ (few-shot list anchors via `toAnchorMembers`/`by.*`, top-`N` mean, mix normalized to sum 1, concept
180
+ anchors preflighted), wired to the existing `Decider`. `audioVectors`/`audioScores` need the authed
181
+ client (injected as `RoomDeps.client`; a loginless `joinRoom` handle throws). `Space.model` now
182
+ surfaced. The recorder matches Python's write semantics exactly: each `session.event` POST is
183
+ awaited **inline** in the observe-stream read loop (backpressure — one write in flight, no unbounded
184
+ queue), a write refused with status < 500 (≠ 429) is **terminal** (`.error` set, the recorder-changed
185
+ seam fires so a bound capture offer disengages), and a transient 5xx/429/network write blip drops +
186
+ reconnects the stream (the gap is lost, capture continues).
187
+ - **Wave 1 (consumers & transport, 2026-07-18):** SSE receive + POST-send room transport with the
188
+ WS→SSE fallback ladder (`transport: "auto" | "ws" | "sse"`, `src/rooms/sse.ts` — Python's
189
+ `_RoomDispatcher`/`_events` parity: code-auth, reconnect/backoff, `room.code_rotated` in-band
190
+ terminal, 403/404 terminal; **signals-only**, binary stays WS-only, and rooms have no replay so no
191
+ `Last-Event-ID` cursor); `audio.snippet` **v2 decode** (`decodeAudioSnippetFrame`, source-attributed,
192
+ v1 still decodes); `room.audioClips()` raw-clip async iterator (source/sourceName from the
193
+ subscribe-time roster snapshot); `room.participants()` / `room.has()` typed `Participant` roster
194
+ (merged `role`+`roles`, `connectedAt` as `Date`) over one short observe stream; `dashboardUrl()` /
195
+ `openDashboard()` / `captureUrl()` / `openCapture()` URL helpers.
196
+
197
+ ## Public surface notes (pre-release)
198
+
199
+ - The default `hci-atrium` barrel exports only the two roster *types* an app touches: the parsed
200
+ `Participant` (merged roles / `name` / `connectedAt`, from `rooms/participant.ts`) and the raw
201
+ transport item `RosterEntry` (from `rooms/transport.ts`). Internal helpers — `mergeRoles`,
202
+ `parseParticipant`, `participantMatches`, `readObserveRoster`, `parseAnchorDescriptor` /
203
+ `AnchorDescriptor` — are **no longer re-exported** from the top-level entry (they stay importable
204
+ internally via `rooms/index`). Also on the record: the `Participant` export was re-pointed to the
205
+ parsed view and the raw roster item renamed to `RosterEntry`. Pre-release, no consumers.
206
+ - **Barrel trim, 2026-07-26 (ergonomics pass).** The root `hci-atrium` entry now exports the two room
207
+ handles (`Room`, `OwnedRoom`), the values their methods hand back (`Recorder`, `Transcriber`,
208
+ `OfferedCapture`, `OfferedTranscription`, `RoomTrace`, `Dashboard`, `Decider`, …), the models, the
209
+ errors, and the option/event types an app names. **Demoted** (still importable from their modules,
210
+ just off the barrel): the audio/snippet frame codec and its `AUDIO_SNIPPET_MAGIC` / `*_VERSION`
211
+ constants, every role string (`AUDIO_ROLE`, `*_BROKER_ROLE`, `CAPTURE_*`, `TRANSCRIBER_SOURCE`, …),
212
+ the transport internals (`RoomEventStream`, `RoomTransport`, `RoomSignal`), the offers' machinery
213
+ (`ArmedSet`, `StandingSource`, `OFFER_MAX_SOURCES`), the dependency-injection bags (`RoomDeps`,
214
+ `OwnedRoomDeps`, `RoomAudioClient`, `OfferDeps`, `CaptureOfferDeps`, `TranscriberDeps`, …),
215
+ `validateChatMessages`, and the trace dialect helpers (`traceEnvelope`, `traceCallType`,
216
+ `traceStateType`, `TRACE_STATE`, `isTraceEphemeral`). These describe the wire, not the app; the
217
+ hosted tools import none of them.
218
+
219
+ **Demoted is not removed.** 0.3.x shipped these *on the barrel*, so this is a breaking change for
220
+ anyone who named one — the package is on npm and its download count is not zero. The migration path
221
+ is the `./internal/*` subpath export, which mirrors the source tree:
222
+
223
+ ```ts
224
+ import { decodeAudioFrame } from "hci-atrium/internal/rooms/codec.js" // extension optional
225
+ ```
226
+
227
+ It carries **no semver guarantee** — internals move without a major bump, by definition. It exists
228
+ so a 0.3 consumer has a one-line edit instead of a fork, not as a second public API.
229
+
230
+ ## Won't port (by design)
231
+
232
+ | Surface | Reason |
233
+ | --- | --- |
234
+ | CLI (`atrium …`) | Documented parity exception — except `atrium guide`, which the npm package ships to print GUIDE.md. |
235
+ | Home Assistant bridge | Documented parity exception. |
236
+ | Sync client surface | JS is async-native; single-surface SDK is the idiom. |
237
+ | Raw-PCM mic input à la `sounddevice` | Browser equivalent is `MediaRecorder`/AudioWorklet clips — same `transcribeLive` contract, different capture idiom. |
238
+
239
+ ## Known micro-divergences (documented, acceptable)
240
+
241
+ - **Agent tools are descriptors, not decorated functions.** Python's `@client.tool` derives a tool's
242
+ OpenAI schema from the function's signature + docstring; TypeScript erases types and has no
243
+ docstrings, so a tool is an explicit `{ name, description, parameters, execute }` descriptor.
244
+ `parameters` is plain JSON Schema, or `{ jsonSchema, validate? }` where `validate` is any
245
+ [Standard Schema](https://standardschema.dev) object (Zod ≥3.24, Valibot, ArkType — the `~standard`
246
+ property) used to parse the model's arguments before `execute` runs. A **bare** schema object is
247
+ rejected with a message naming the fix: deriving JSON Schema from it would mean taking the library
248
+ as a runtime dependency, and the package is zero-dependency by design (Zod stays an *optional* peer,
249
+ loaded lazily only by structured output). One more small deviation inside the loop: a tool's return
250
+ value is JSON-encoded when it's an object (JS's `String({})` is the useless `"[object Object]"`,
251
+ where Python's `str(dict)` is readable).
252
+ - **No `DirectTransports`.** Python caches one `httpx.Client` per direct endpoint and closes them with
253
+ the client; `fetch` is stateless, so TS has nothing to pool or close. The per-endpoint timeout
254
+ becomes an `AbortSignal.timeout` folded into each call's signal — and it is in **milliseconds**
255
+ (the JS unit), not Python's seconds.
256
+ - **`surveys.responsesCsv` is its own method**, where Python has `responses(format="rows"|"csv")`.
257
+ The two return types have nothing in common (parsed rows vs raw bytes), so a union return would be
258
+ a type-level lie the caller has to narrow — the same call the `sessions.export` port already made.
259
+ Also, as there: no `path=` file-writing, since the core never touches the filesystem.
260
+ - `surveys.start({ into })` takes a `Session` handle **or** a bare session id — unchanged. What
261
+ changed (2026-07-26, **overturning** the note that used to live here): TS's `Session` is no longer
262
+ an inert read model. It is a class with a truly-private `#binder`, exactly like `Asset`, carrying
263
+ `session.event(type, payload?, opts?)` bound to the client that produced it — the twin of Python's
264
+ `Session.event`. Python's instance offers *only* `event`, so that is all TS attaches; the id-based
265
+ `sessions.*` namespace (`sessions.event(id, …)`, `sessions.export(id)`, …) stays the complete
266
+ surface and is what an unbound session's error message names. A `Session` built by hand is now a
267
+ `parseSession({...})` call rather than an object literal — a class with a private field cannot be
268
+ satisfied structurally.
269
+ - **`chatStream` is a separate method**, where Python has `chat(..., stream=True)`. A flag that swaps
270
+ the return type between `Promise<ChatReply>` and `AsyncIterable<string>` forces every call site to
271
+ narrow a union it already knows the answer to, and Python's dynamic `stream=True` has no such cost.
272
+ The behaviour is identical either way (the iterable is returned **synchronously** — nothing to
273
+ `await` before the `for await`, same as Python's generator), so this is a type-soundness divergence
274
+ only. It also retires a runtime error: "stream: true can't be combined with a schema
275
+ `responseFormat`" is now unrepresentable, since structured output lives on `chat` alone.
276
+ - A **streamed** chat is traced at hand-off (one `trace.chat`, no reply preview, ~0 duration) —
277
+ mirroring Python, whose decorator emits when the generator is returned, before anything is consumed.
278
+ - **`Room` / `OwnedRoom` split.** Python's `Room` carries its client, so its host-side verbs are
279
+ always callable and the loginless `join_room` handle is a different class already. TS used to hand
280
+ back one `Room` for both and refuse at runtime (`requireClient` → "no account credential"). The
281
+ ~14 credential-bound members now live on `OwnedRoom extends Room`, which `client.rooms.open/get/list`
282
+ return; `joinRoom(code)` **and** `client.rooms.join(code)` return the participant `Room`. Same
283
+ capability boundary, moved from the runtime to the type — `room.record(...)` on a code-joined handle
284
+ is a compile error, not a thrown one. `rooms.join` stays a plain `Room` deliberately: it is built
285
+ without the REST caller and capability slice (a code grants live transport, not the account), so
286
+ typing it `OwnedRoom` would advertise members that throw.
287
+ - **`chat` / `agent` passthrough is an explicit `params` bag**, where Python takes `**kwargs`. TS's
288
+ equivalent was an index signature (`[key: string]: unknown`) on the options interface, which made
289
+ every misspelled SDK option — `responseFormatt`, `modle` — a silently-forwarded OpenAI param. The
290
+ bag keeps the capability (`params: { temperature: 0.3 }`) and gets the typo checking back; SDK-owned
291
+ keys are applied last and win a collision. Same wire.
292
+ - **`room.send` carries `to` + `replyTo` only**, where Python's `Room.send` also takes `ephemeral`.
293
+ Python sends over the REST `/v1/rooms/{id}/signal` POST, which carries the flag; TS sends over the
294
+ room WebSocket, whose inbound path forces `ephemeral: false` server-side. An `ephemeral=` parameter
295
+ on the TS `send` would be a field the transport silently drops, so it isn't offered — the surfaces
296
+ that need ephemerality (`room.dashboard()`, `room.trace()`, `room.transcribe()`, the standing
297
+ offers, `audioVectors({relay: true})`) all publish through the REST POST instead, which is why they
298
+ are `OwnedRoom` members. `ackId` is likewise absent: a reply needs a registered pending entry, and
299
+ only `room.request(...)` creates one.
300
+ - **Base-URL resolution is one helper** (`resolveBaseUrl`, `src/env.ts`) shared by the client,
301
+ `joinRoom`, and the React `useRoomConnection` hook: explicit `baseUrl` → `ATRIUM_BASE_URL` →
302
+ (hook only) the page's own origin → the deployed default. The hook's origin rung is the one
303
+ difference, deliberate — it is browser-only, and a hosted tool page served *by* an instance should
304
+ talk to that instance. **Behavior change (2026-07-26):** the hook previously hardcoded
305
+ `location.origin`, so a tool pointed elsewhere at build time via `ATRIUM_BASE_URL` silently ignored
306
+ it; env is now consulted first. A page that relied on origin-wins while also setting the env var
307
+ resolves differently.
308
+ - `room.play` for byte-backed assets: TS sends compact `{asset_id}`, Python sends a playback
309
+ intent. Both shapes work at the Player.
310
+ - **Player dialect arguments are typed in both spellings.** `room.play(track)` and
311
+ `room.configureLayers(layers)` accept the SDK's camelCase (`PlayTrack` / `LayerDeclaration`) *or*
312
+ the dialect's own snake_case (`WireTrack` / `LayerSpec`, from the `hci-atrium/player` subpath),
313
+ translating the former at the shaping seam. Python takes the wire dict directly and has no second
314
+ spelling. A key belonging to neither is a compile error, which is the point: the runtime always
315
+ accepted a hand-rolled wire descriptor, and before this the type didn't. The read-back
316
+ (`queryLayers` / `watchLayers`) still hands back the raw wire snapshot, snake_case — it is the
317
+ Player's payload, not an SDK model.
318
+ - **One `PlaybackIntent`.** The `{source, ref, mode?}` intent is defined once in `models.ts` and
319
+ re-exported by `hci-atrium/player`'s `play-command`, so the type an app reads off `asset.playback`
320
+ is the exact type the Player runtime dispatches on. `ref` is therefore nullable there too (a
321
+ description-only asset has no target): `routePlayIntent` reads a missing ref as "nothing to play",
322
+ and narrows `mode` to the `"shuffle"` the Spotify resolver understands.
323
+ - `room.play`'s `asset` track key (landed both sides 2026-07-26, together with the `gain` per-call
324
+ option: `play({asset: hit, gain: 2})` / `play(source, {gain: 2})`, per-track gain winning): what
325
+ counts as "an asset under `asset`" is decided differently. Python expands anything that isn't a
326
+ `dict` (an `Asset`/`SearchHit`/id string — anything else raises the usual `TypeError`) and leaves a
327
+ `dict` alone, since that's the wire's inline-asset descriptor; TS has no dict/object split, so it
328
+ expands only a value that *looks* like a parsed `Asset` (`isAssetLike`), a `SearchHit`
329
+ (`score` + `matchedBy`), or a string, and passes anything else through untouched. Same rule for
330
+ every SDK-object call, same wire; only a hand-rolled oddity under `asset` behaves differently
331
+ (Python throws where TS ships it as-is) — the same heuristic trade-off as the `isAssetLike` note above.
332
+ - Live-transcription WS auth: Python rides an `Authorization: Bearer` header on the handshake; the TS
333
+ client can't (browsers forbid custom WS headers), so it carries the credential in-band as the
334
+ `start` frame's `api_key` field — the backend's browser-safe first-frame path (accept → read
335
+ `start` → pop `api_key` → validate). Same credential, different carrier; and unlike the legacy
336
+ `?api_key=` query the backend still tolerates, it never rides the URL into proxy/access logs. A
337
+ rejected key closes `4401` after the socket opened, still surfaced as `AuthError` (never a silent
338
+ degrade to the per-clip batch fallback).
339
+ - `room.dashboard()` and `room.trace()` publish over the REST `/v1/rooms/{id}/signal` POST for the
340
+ same reason `room.transcribe()` does (below): the WS inbound path forces `ephemeral: false`, and
341
+ both dialects are ephemerality-sensitive (all `dash.*` is ephemeral; a trace is durable *unless* it
342
+ mirrors something already durable). Both therefore need an **owned** room — a loginless `joinRoom`
343
+ handle throws, where Python's `Dashboard(room)`/`room.trace()` ride its REST `send` unconditionally.
344
+ A bare `new Dashboard(roomLike)` (the decoupled/test path) still uses the room-like's own `send`.
345
+ - Trace re-entrancy: Python needs a `contextvars` guard because its structured-output `chat` recurses
346
+ through the *traced* `chat`. The TS client's structured path calls the **untraced** private
347
+ `chatOnce` instead, so one logical call emits exactly one `trace.chat` with no task-local state.
348
+ - `room.audioTranscripts()` teardown: a consumer's `break` can't cancel the engine's pending feeder
349
+ `await` the way Python cancels its feeder task, so the iterator holds its **own** `AbortController`
350
+ (chained to an optional `signal`), aborts it in a `finally` **before** winding the engine down —
351
+ closing the snippet socket is what unblocks the feeder — and pulls the engine by hand rather than
352
+ `yield*` (which would forward `return()` into a still-parked engine and deadlock). Same documented
353
+ limitation family as the transcription teardown note below.
354
+ - `room.transcribe()` publishes its `transcript.*` signals over the REST `/v1/rooms/{id}/signal` POST
355
+ (like Python's `room.send`), **not** the room WebSocket — because the WS inbound path forces
356
+ `ephemeral: false`, and the recorder's finals-vs-partials split needs the partial's `ephemeral: true`
357
+ flag to survive on the wire. So the broker's ephemeral hint is honored identically to Python's.
358
+ - Transcription teardown: JS can't hard-cancel a pending `await` the way Python cancels the feeder
359
+ task, so `stop()` aborts via an `AbortSignal` (closing the snippet socket + the stream WS) and the
360
+ feeder stops at its next resumption point / on the input iterator's `return()`. A well-behaved input
361
+ ends promptly; a pathological input blocked forever on its own `await` is the one case that can't be
362
+ force-unblocked (documented limitation, not reachable from the room snippet path, which ends on
363
+ socket close).
364
+ - Offer source-name resolution: Python's `RoomWebSocket.frames(on_presence=…)` folds presence into a
365
+ roster on the *same* loop that yields binary frames, so each frame resolves its source against the
366
+ roster as it was when that frame was processed. The TS transport folds its live roster synchronously
367
+ on receipt (decoupled from the offer's async consumption), so a burst of presence + binary would race
368
+ the roster ahead. The offer therefore keeps its **own** point-in-time roster (`StandingSource` — name
369
+ + roles), seeded from the ready snapshot and folded in loop order, and resolves `source_name` /
370
+ all-arm enumeration off *that*, not the transport's live roster. Same observable behaviour, folded on
371
+ the offer's clock.
372
+ - Offer engine teardown: a standing transcription offer holds a **per-source** `AbortController` (not
373
+ one shared signal). A per-source `transcription.stop` / a source's `presence.leave` — and the whole
374
+ `stop()` — abort that source's signal AND drop its buffered clips (`ClipQueue.clear()` + a `null`
375
+ end-sentinel), matching Python's `engine.cancel()`: buffered clips are discarded and nothing is
376
+ published past the disarm boundary (consent-relevant — no captions after a source stops/leaves). The
377
+ per-source `stopSource` runs under the offer's `Mutex`, like capture's.
378
+ - The offer-capture chunked recording wire (`POST …/recordings` / `PUT …/chunks` / `POST …/finalize`)
379
+ is pinned by byte-exact assertions in `tests/offers.test.ts` (paths, `{source_name, content_type}`
380
+ body, chunk `Content-Type` + bytes) — mirroring Python's `test_offers.py` — rather than added to the
381
+ shared `wire-fixtures` golden corpus, which has no binary-body channel and whose Python side builds
382
+ these calls inline (no shaping-builder call-site to register). Same wire, pinned in both suites.
383
+
384
+ ## TS-first (Python owes the port)
385
+
386
+ | Surface | Notes / porting trigger |
387
+ | --- | --- |
388
+ | `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)`. |
389
+ | `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. |
390
+ | `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`. |
391
+
392
+ *(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
@@ -249,11 +270,17 @@ plus the hosted tool's `url`. Hand either to the participant-facing environment,
249
270
  a tab on the machine itself.
250
271
 
251
272
  `surveys.template(name, body)` is the code-first **ensure** flow: run it on every start-up. It
252
- creates the template if it's missing, appends a new immutable version only when the body actually
253
- changed, and flips `default` when asked — so your templates live in your repo, not in a UI.
273
+ creates the template if it's missing, writes a new version only when the body actually changed, and
274
+ flips `default` when asked — so your templates live in your repo, not in a UI. It returns
275
+ `{name, version, isDefault, changed, created}`: read `changed`, not the version number, to tell a
276
+ write from a no-op — the server is copy-on-write, so a real write keeps the current version number
277
+ while no response is pinned to it. `description` is applied on **create** only.
254
278
 
255
279
  ```ts
256
- await atrium.surveys.template("post-study", postStudyBody, { default: true })
280
+ await atrium.surveys.template("post-study", postStudyBody, {
281
+ default: true,
282
+ description: "the post-study questionnaire", // create only — ignored once it exists
283
+ })
257
284
 
258
285
  const session = await atrium.sessions.start({ participantRef: "p-07" })
259
286
  await session.event("phase.start", { phase: "post-study" }) // bound to the session it came from
@@ -262,6 +289,7 @@ const survey = await atrium.surveys.start({
262
289
  template: "post-study",
263
290
  into: session,
264
291
  returnTo: "https://study.example/next",
292
+ linkParams: { modules: "build,test" }, // query params on survey.url, before its #token= fragment
265
293
  })
266
294
  survey.open() // …or send survey.url / survey.token to the participant's device
267
295
 
package/bin/atrium.mjs ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ // `atrium guide` — print the SDK guide (GUIDE.md) for piping into a coding agent.
3
+ // The TypeScript package ships only this one subcommand; the full CLI is Python-side.
4
+ import { existsSync, readFileSync } from "node:fs"
5
+ import { fileURLToPath } from "node:url"
6
+
7
+ const USAGE = `usage: atrium guide
8
+
9
+ Prints the SDK guide (GUIDE.md) for piping into a coding agent.
10
+ The TypeScript package ships only \`atrium guide\`; the full CLI
11
+ (login, whoami, search, …) comes with the Python package —
12
+ \`uv add hci-atrium\`, then \`uv run atrium --help\`.`
13
+
14
+ const command = process.argv[2]
15
+
16
+ if (command === "guide") {
17
+ // Source checkout first (clients/python/GUIDE.md is the canonical, always-current copy — a
18
+ // stale pack-time copy at the package root must never shadow it), then the packaged location.
19
+ const candidates = ["../../python/GUIDE.md", "../GUIDE.md"].map((rel) =>
20
+ fileURLToPath(new URL(rel, import.meta.url)),
21
+ )
22
+ const found = candidates.find((path) => existsSync(path))
23
+ if (!found) {
24
+ console.error("error: GUIDE.md not found (neither bundled nor in the source tree)")
25
+ process.exit(1)
26
+ }
27
+ process.stdout.write(readFileSync(found, "utf8"))
28
+ } else if (["help", "--help", "-h"].includes(command)) {
29
+ console.log(USAGE)
30
+ } else if (command === undefined) {
31
+ console.error(USAGE)
32
+ } else {
33
+ console.error(`error: unknown command '${command}'\n\n${USAGE}`)
34
+ process.exit(1)
35
+ }
package/dist/atrium.d.ts CHANGED
@@ -508,12 +508,20 @@ export interface SurveyStartOptions {
508
508
  /** A url the survey's end page can send the participant back to — an ``app``-controlled return
509
509
  * button in the template resolves to it (the button's label stays template-controlled). */
510
510
  returnTo?: string;
511
+ /** Query parameters appended to the returned {@link Survey.url} (client-side only), inserted
512
+ * BEFORE its ``#token=`` fragment so the page actually reads them: ``{ modules: "build,test" }``
513
+ * to pick the template's page modules, or any question's ``param`` binding (see
514
+ * ``docs/reference/survey-template-dialect.md``). */
515
+ linkParams?: Record<string, string>;
511
516
  signal?: AbortSignal;
512
517
  }
513
518
  export interface SurveyTemplateOptions {
514
519
  project?: string;
515
520
  /** Make this the project's default template (the one ``start()`` picks with no template named). */
516
521
  default?: boolean;
522
+ /** The template's envelope description — applied on CREATE only, like the SPA's "load into a new
523
+ * template"; on a template that already exists it is ignored. */
524
+ description?: string;
517
525
  signal?: AbortSignal;
518
526
  }
519
527
  export interface SurveyResponsesOptions {
@@ -742,20 +750,31 @@ export declare class Surveys {
742
750
  * Start a survey on a session: creates the data object (pinning the template's current version —
743
751
  * or the project's DEFAULT template when ``template`` is omitted) and returns the handle with its
744
752
  * once-only token. Options-only — ``start({ template: "post-study", into: session })``, or just
745
- * ``start({ into: session })`` for the default template.
753
+ * ``start({ into: session })`` for the default template. ``linkParams`` composes the returned
754
+ * ``url``'s query string (``modules``, a question's ``param``) — see {@link SurveyStartOptions}.
746
755
  */
747
756
  start(opts: SurveyStartOptions): Promise<Survey>;
748
757
  /**
749
758
  * Ensure a template exists with exactly this body (idempotent, code-first).
750
759
  *
751
- * Create it if missing; if it exists and the CURRENT version's body differs, append a new
752
- * immutable version; flip the default via PATCH when requested and not already set. Returns
753
- * ``{name, version, isDefault}``. Safe to rerun on every start-up.
760
+ * Create it if missing; if it exists and the CURRENT version's body differs, save the new body
761
+ * (the server is copy-on-write: it overwrites the current version in place while no response is
762
+ * pinned to it, so a real write may keep the same version number, and forks the next version once
763
+ * one is); flip the default via PATCH when requested and not already set.
764
+ *
765
+ * ``description`` is the template's envelope description — applied on CREATE only, like the SPA's
766
+ * "load into a new template"; on an existing template it is ignored.
767
+ *
768
+ * Returns ``{name, version, isDefault, changed, created}`` — ``changed`` is false exactly when the
769
+ * stored body already matched, so the version number alone can't tell a no-op from a write. Safe
770
+ * to rerun on every start-up.
754
771
  */
755
772
  template(name: string, body: unknown, opts?: SurveyTemplateOptions): Promise<{
756
773
  name: string;
757
774
  version: number;
758
775
  isDefault: boolean;
776
+ changed: boolean;
777
+ created: boolean;
759
778
  }>;
760
779
  /** List the project's survey templates (``GET …/survey-templates``). */
761
780
  templates(opts?: {