hci-atrium 0.4.0 → 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/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
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hci-atrium",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "TypeScript SDK for the Atrium framework — multimodal asset library, semantic + cross-modal search, and capability routing.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,8 +10,14 @@
10
10
  "node": ">=20.4"
11
11
  },
12
12
  "files": [
13
- "dist"
13
+ "dist",
14
+ "bin",
15
+ "GUIDE.md",
16
+ "PARITY.md"
14
17
  ],
18
+ "bin": {
19
+ "atrium": "bin/atrium.mjs"
20
+ },
15
21
  "main": "./dist/index.js",
16
22
  "types": "./dist/index.d.ts",
17
23
  "exports": {