dsh-realtime 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Travis Driessen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,69 @@
1
- # Temporary Holding Version
1
+ # dsh-realtime
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **A realtime voice capability seam for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).**
4
+
5
+ DSH ships ~40 `ctx.*` capability seams (`ctx.llm`, `ctx.tools`, `ctx.subagents`, …) and **no voice
6
+ seam of any kind**. This package is the proposal and the implementation of that missing seam: a
7
+ provider registry over the live-session vocabulary, plus the adapter base every voice backend extends.
8
+
9
+ > **Status: staged.** This is a third-party proposal, not an official `@deepseek-ai` package. It is
10
+ > built to the harness's package conventions so it can be contributed upstream without restructuring.
11
+
12
+ ## The model
13
+
14
+ A **provider registry**, mirroring `ctx.llm`:
15
+
16
+ ```ts
17
+ export const name = 'realtime-openai-live'
18
+ export const inject = ['realtime']
19
+
20
+ export function apply(ctx: Context, config: Config) {
21
+ ctx.realtime.registerAdapter(['openai-live'], new OpenAiLiveAdapter(config))
22
+ }
23
+ ```
24
+
25
+ The seam is deliberately thin. It owns three things:
26
+
27
+ | Owned here | Owned by the adapter |
28
+ |---|---|
29
+ | Route registration, all-or-nothing, disposed with the fiber | The transport (WebSocket, WebRTC, in-process) |
30
+ | Provider metadata and advisory model catalogues | Wire encoding and decoding |
31
+ | Append bounds (`MAX_APPEND_CHARS`) and coded failures | Token accounting and provider error mapping |
32
+
33
+ ## What the seam deliberately does not have
34
+
35
+ - **No end-of-utterance call.** Endpointing belongs to the voice provider. A client-side detector
36
+ would be a second, competing turn boundary — and the protocol family this targets has no commit
37
+ event at all, so the absence is enforced upstream, not merely preferred here.
38
+ - **No turn management.** Turn-taking is session state, not conversation state. `sendAudio` pushes
39
+ frames and callbacks deliver what happens; nothing here decides who speaks next.
40
+ - **No auto-answer for delegations.** `onDelegation` hands the consumer a delegation and the
41
+ consumer decides. There is no default path that answers on the consumer's behalf, because a
42
+ default that resolves work the application has not authorised is exactly the failure this design
43
+ refuses to make possible.
44
+
45
+ ## Failure semantics
46
+
47
+ Failures that prevent a session opening are **thrown** from `session()`. Failures after a session
48
+ opened are delivered to `handlers.onError` — a session that dies mid-conversation must not look like
49
+ a rejected request. Codes are stable and branchable; message text is not part of the API.
50
+
51
+ ## Registration and disposal
52
+
53
+ `registerAdapter` validates the whole candidate set before mutating anything, so a rejected
54
+ registration leaves the registry exactly as it was. The registration is created through `ctx.effect`,
55
+ so it is withdrawn with the contributing fiber — HMR unmounts routes rather than leaking them.
56
+
57
+ ## Known limitations and deferred work
58
+
59
+ - **Append bounds are characters, not tokens.** The provider's real limit is 500 tokens. Counting
60
+ tokens exactly would bind this seam to a tokenizer it does not own, so `MAX_APPEND_CHARS` is a
61
+ documented conservative proxy. An adapter that can count tokens should tighten it rather than
62
+ widening the seam's promise.
63
+ - **No session resumption.** The protocol fixes provider, model, voice and delegation mode at
64
+ startup; a seam-level `resume` would advertise a capability the wire cannot honour.
65
+ - **The model catalogue is advisory.** Absence from `listModels` must never become request rejection.
66
+ - **Build layout deviates from the harness's.** A `@deepseek-ai` package compiles to `lib/types` and
67
+ ships a separately bundled `lib/index.js`; this repo compiles flat to `lib/`. The `main`/`types`
68
+ entries and the `.ts`-extension import style match, so the source is portable — but packaging must
69
+ be reconciled before an upstream PR. Tracked here rather than silently diverging.
package/lib/error.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Typed failures for the realtime seam, carrying stable machine codes.
3
+ *
4
+ * Codes are the seam's API: a consumer branches on `code`, never on message text. Adding a code is a
5
+ * minor release; changing what an existing code means is a breaking one.
6
+ *
7
+ * @module dsh-realtime/error
8
+ */
9
+ /** Stable machine codes raised by this seam. */
10
+ export declare const REALTIME_ERROR_CODES: Readonly<{
11
+ /** A registration named an empty or malformed provider route. */
12
+ INVALID_PROVIDER: "INVALID_PROVIDER";
13
+ /** A route already has an adapter registered by another registration. */
14
+ DUPLICATE_PROVIDER: "DUPLICATE_PROVIDER";
15
+ /** Registration was released, so it can no longer replace its routes. */
16
+ REGISTRATION_DISPOSED: "REGISTRATION_DISPOSED";
17
+ /** No adapter is registered for the requested route. */
18
+ NO_ADAPTER: "NO_ADAPTER";
19
+ /** An append was empty, not a string, or over the seam's bound. */
20
+ INVALID_APPEND: "INVALID_APPEND";
21
+ /** The session has already been closed. */
22
+ SESSION_CLOSED: "SESSION_CLOSED";
23
+ /** The credential an adapter needs is absent or unusable. Names the setting, never the value. */
24
+ MISSING_CREDENTIAL: "MISSING_CREDENTIAL";
25
+ /** The provider reported a failure, or an operation it was expected to acknowledge never was. */
26
+ PROVIDER_ERROR: "PROVIDER_ERROR";
27
+ /** A recorded session could not be read as a recording. */
28
+ INVALID_RECORDING: "INVALID_RECORDING";
29
+ }>;
30
+ /** One of {@link REALTIME_ERROR_CODES}. */
31
+ export type RealtimeErrorCode = (typeof REALTIME_ERROR_CODES)[keyof typeof REALTIME_ERROR_CODES];
32
+ /**
33
+ * A typed seam failure.
34
+ *
35
+ * The constructor validates its own arguments rather than trusting callers: a failure raised while
36
+ * reporting a failure is the worst place to discover a malformed argument.
37
+ */
38
+ export declare class RealtimeError extends Error {
39
+ /** Stable machine code. Branch on this, never on `message`. */
40
+ readonly code: RealtimeErrorCode;
41
+ /**
42
+ * @param message - non-empty human-readable summary. Must not contain secret material.
43
+ * @param code - one of {@link REALTIME_ERROR_CODES}.
44
+ * @param options - optional `cause`.
45
+ */
46
+ constructor(message: string, code: RealtimeErrorCode, options?: ErrorOptions);
47
+ }
48
+ //# sourceMappingURL=error.d.ts.map
package/lib/error.js ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Typed failures for the realtime seam, carrying stable machine codes.
3
+ *
4
+ * Codes are the seam's API: a consumer branches on `code`, never on message text. Adding a code is a
5
+ * minor release; changing what an existing code means is a breaking one.
6
+ *
7
+ * @module dsh-realtime/error
8
+ */
9
+ /** Stable machine codes raised by this seam. */
10
+ export const REALTIME_ERROR_CODES = Object.freeze({
11
+ /** A registration named an empty or malformed provider route. */
12
+ INVALID_PROVIDER: 'INVALID_PROVIDER',
13
+ /** A route already has an adapter registered by another registration. */
14
+ DUPLICATE_PROVIDER: 'DUPLICATE_PROVIDER',
15
+ /** Registration was released, so it can no longer replace its routes. */
16
+ REGISTRATION_DISPOSED: 'REGISTRATION_DISPOSED',
17
+ /** No adapter is registered for the requested route. */
18
+ NO_ADAPTER: 'NO_ADAPTER',
19
+ /** An append was empty, not a string, or over the seam's bound. */
20
+ INVALID_APPEND: 'INVALID_APPEND',
21
+ /** The session has already been closed. */
22
+ SESSION_CLOSED: 'SESSION_CLOSED',
23
+ /** The credential an adapter needs is absent or unusable. Names the setting, never the value. */
24
+ MISSING_CREDENTIAL: 'MISSING_CREDENTIAL',
25
+ /** The provider reported a failure, or an operation it was expected to acknowledge never was. */
26
+ PROVIDER_ERROR: 'PROVIDER_ERROR',
27
+ /** A recorded session could not be read as a recording. */
28
+ INVALID_RECORDING: 'INVALID_RECORDING',
29
+ });
30
+ /**
31
+ * A typed seam failure.
32
+ *
33
+ * The constructor validates its own arguments rather than trusting callers: a failure raised while
34
+ * reporting a failure is the worst place to discover a malformed argument.
35
+ */
36
+ export class RealtimeError extends Error {
37
+ /** Stable machine code. Branch on this, never on `message`. */
38
+ code;
39
+ /**
40
+ * @param message - non-empty human-readable summary. Must not contain secret material.
41
+ * @param code - one of {@link REALTIME_ERROR_CODES}.
42
+ * @param options - optional `cause`.
43
+ */
44
+ constructor(message, code, options) {
45
+ if (typeof message !== 'string' || message.length === 0) {
46
+ throw new TypeError('RealtimeError message must be a non-empty string');
47
+ }
48
+ if (typeof code !== 'string' || code.length === 0) {
49
+ throw new TypeError('RealtimeError code must be a non-empty string');
50
+ }
51
+ super(message, options);
52
+ this.name = 'RealtimeError';
53
+ this.code = code;
54
+ }
55
+ }
56
+ //# sourceMappingURL=error.js.map
package/lib/index.d.ts ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Realtime voice seam: a provider registry plus the adapter base every voice backend extends.
3
+ *
4
+ * Exports the `RealtimeRuntime` service as the **default** export (a DeepSeek Harness convention for
5
+ * service packages) and the abstract `RealtimeAdapter` for provider backends. Function plugins
6
+ * must supply `name` / `inject` / `Config` / `apply` and no default export; this package is a
7
+ * service package, so it is mounted as a class plugin and default-exports its service.
8
+ *
9
+ * @module dsh-realtime
10
+ */
11
+ import { Service, type Context } from '@deepseek-ai/cordis';
12
+ import type { RealtimeDelegation, RealtimeModelInfo, RealtimeProviderInfo, RealtimeSession, RealtimeSessionHandlers, RealtimeSessionOptions } from './types.ts';
13
+ export * from './types.ts';
14
+ export * from './error.ts';
15
+ export { RealtimeError, REALTIME_ERROR_CODES } from './error.ts';
16
+ declare module '@deepseek-ai/cordis' {
17
+ interface Context {
18
+ realtime: RealtimeRuntime;
19
+ }
20
+ }
21
+ /**
22
+ * What {@link RealtimeRuntime.registerAdapter} returns: the disposer, plus an atomic route
23
+ * replacement for the same adapter instance.
24
+ */
25
+ export interface AdapterRegistrationHandle {
26
+ /** Release every route this registration currently holds. */
27
+ (): void;
28
+ /**
29
+ * Replace this registration's routes, keeping the same adapter instance. The candidate set is
30
+ * validated in full first — a conflict with another registration or a malformed route throws and
31
+ * leaves the current routes untouched — and the swap is one synchronous section, so no observer
32
+ * can see the registry between release and re-registration.
33
+ *
34
+ * An empty array is legal here (a plugin whose configuration emptied holds zero routes while
35
+ * staying registered), unlike an empty initial registration.
36
+ *
37
+ * Throws `REGISTRATION_DISPOSED` once the registration was released: its routes are gone and its
38
+ * disposer has already run, so anything registered afterwards would have no owner left to release it.
39
+ * @param providers - the complete next route set for this registration.
40
+ */
41
+ replace(providers: string[]): void;
42
+ }
43
+ /**
44
+ * Provider-wire adapter for the realtime session vocabulary.
45
+ *
46
+ * Register implementations with `ctx.realtime.registerAdapter(providers, adapter)`. The single
47
+ * required method is {@link session}; every other method exists so a provider can describe itself
48
+ * without the seam having to special-case it.
49
+ */
50
+ export declare abstract class RealtimeAdapter {
51
+ /**
52
+ * Describe one provider route owned by this adapter.
53
+ * @param provider - a route passed to `registerAdapter()` for this instance.
54
+ * @returns detached display metadata whose `id` must equal `provider`.
55
+ */
56
+ providerInfo(provider: string): RealtimeProviderInfo;
57
+ /**
58
+ * List the voice models this adapter can currently advertise for one owned route.
59
+ *
60
+ * The result is advisory: an adapter may accept unlisted model ids, and consumers must not turn
61
+ * absence into request rejection.
62
+ * @param _provider - one provider route owned by this adapter.
63
+ * @returns discoverable models in adapter-preferred order.
64
+ */
65
+ listModels(_provider: string): Promise<readonly RealtimeModelInfo[]>;
66
+ /**
67
+ * Open one voice session. The only required method.
68
+ *
69
+ * Implementations must honor `options.signal` during establishment, and must throw (rather than
70
+ * resolve with a dead session) when the session cannot open.
71
+ * @param options - the fully-resolved request; `options.provider` selects the registered route.
72
+ * @returns the live session, with `options.handlers` already wired.
73
+ */
74
+ abstract session(options: RealtimeSessionOptions): Promise<RealtimeSession>;
75
+ }
76
+ /**
77
+ * The `realtime` service: an adapter registry over the session vocabulary.
78
+ *
79
+ * Registration is effect-based, so HMR or a disposal unmounts routes with the contributing fiber —
80
+ * there is no separate teardown path that can be forgotten.
81
+ */
82
+ export declare class RealtimeRuntime extends Service {
83
+ private readonly adapters;
84
+ /**
85
+ * @param ctx - the Cordis context this service is mounted on.
86
+ */
87
+ constructor(ctx: Context);
88
+ /**
89
+ * Register an adapter for the given provider routes, all-or-nothing.
90
+ *
91
+ * Disposed with the fiber. Throws `INVALID_PROVIDER` for a malformed route, `DUPLICATE_PROVIDER`
92
+ * if any route is already held by another registration.
93
+ * @param providers - every provider route this adapter should serve.
94
+ * @param adapter - the adapter that opens sessions for those routes.
95
+ * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.
96
+ */
97
+ registerAdapter(providers: string[], adapter: RealtimeAdapter): AdapterRegistrationHandle;
98
+ /**
99
+ * Validate one candidate route set for `adapter`, treating routes this registration already holds
100
+ * as available.
101
+ *
102
+ * Nothing is mutated: a rejected candidate leaves the registry exactly as it was, which is what
103
+ * makes {@link AdapterRegistrationHandle.replace} a swap rather than a delete-then-add that can
104
+ * strand the registry empty.
105
+ * @param providers - candidate routes.
106
+ * @param adapter - the adapter that would own them.
107
+ * @param owned - routes this same registration already holds.
108
+ * @returns registrations ready to commit.
109
+ */
110
+ private prepare;
111
+ /**
112
+ * Swap this registration's routes for the prepared ones in one synchronous section, so no
113
+ * observer can see the registry between the release and the re-registration.
114
+ * @param owned - mutable set tracking which routes this registration holds.
115
+ * @param registrations - validated registrations to install.
116
+ */
117
+ private commit;
118
+ /**
119
+ * Describe the provider routes that currently have an adapter.
120
+ * @returns detached provider metadata in registration order.
121
+ */
122
+ listProviders(): RealtimeProviderInfo[];
123
+ /**
124
+ * Resolve the adapter owning one route.
125
+ * @param provider - registered route to look up.
126
+ * @returns that route's registration.
127
+ * @throws RealtimeError `NO_ADAPTER` when the route is unregistered.
128
+ */
129
+ private registration;
130
+ /**
131
+ * Discover the voice models one registered route advertises.
132
+ * @param provider - registered route to inspect.
133
+ * @returns detached model metadata in adapter-preferred order, duplicates removed.
134
+ */
135
+ listModels(provider: string): Promise<RealtimeModelInfo[]>;
136
+ /**
137
+ * Open one voice session through the adapter registered for its route.
138
+ *
139
+ * A field this seam cannot honor is rejected here rather than forwarded as a no-op — the caller
140
+ * learns the request is unsupported before a live conversation depends on it.
141
+ * @param options - the session request; `options.provider` selects the adapter.
142
+ * @returns the adapter's live session.
143
+ * @throws RealtimeError `NO_ADAPTER` for an unregistered route.
144
+ */
145
+ session(options: RealtimeSessionOptions): Promise<RealtimeSession>;
146
+ /**
147
+ * Validate one context append against the seam's bounds.
148
+ *
149
+ * Exposed so an adapter can enforce the same bound the seam promises, instead of each backend
150
+ * re-deriving it. Bound enforcement lives at the operation that makes the decision: a caller that
151
+ * bypasses this cannot silently send an over-long append.
152
+ * @param content - candidate append text.
153
+ * @param maxChars - character ceiling; defaults to the provider's documented bound.
154
+ * @returns the content unchanged, for convenient inline use.
155
+ * @throws RealtimeError `INVALID_APPEND` for a non-string, empty, or over-long value.
156
+ */
157
+ static assertAppendable(content: string, maxChars: number): string;
158
+ }
159
+ /** Re-exported so adapters can type their handler wiring without a second import. */
160
+ export type { RealtimeDelegation, RealtimeSessionHandlers };
161
+ export default RealtimeRuntime;
162
+ //# sourceMappingURL=index.d.ts.map
package/lib/index.js ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Realtime voice seam: a provider registry plus the adapter base every voice backend extends.
3
+ *
4
+ * Exports the `RealtimeRuntime` service as the **default** export (a DeepSeek Harness convention for
5
+ * service packages) and the abstract `RealtimeAdapter` for provider backends. Function plugins
6
+ * must supply `name` / `inject` / `Config` / `apply` and no default export; this package is a
7
+ * service package, so it is mounted as a class plugin and default-exports its service.
8
+ *
9
+ * @module dsh-realtime
10
+ */
11
+ import { Service } from '@deepseek-ai/cordis';
12
+ import { REALTIME_ERROR_CODES, RealtimeError } from './error.js';
13
+ export * from './types.js';
14
+ export * from './error.js';
15
+ export { RealtimeError, REALTIME_ERROR_CODES } from './error.js';
16
+ /**
17
+ * Provider-wire adapter for the realtime session vocabulary.
18
+ *
19
+ * Register implementations with `ctx.realtime.registerAdapter(providers, adapter)`. The single
20
+ * required method is {@link session}; every other method exists so a provider can describe itself
21
+ * without the seam having to special-case it.
22
+ */
23
+ export class RealtimeAdapter {
24
+ /**
25
+ * Describe one provider route owned by this adapter.
26
+ * @param provider - a route passed to `registerAdapter()` for this instance.
27
+ * @returns detached display metadata whose `id` must equal `provider`.
28
+ */
29
+ providerInfo(provider) {
30
+ return { id: provider, name: provider };
31
+ }
32
+ /**
33
+ * List the voice models this adapter can currently advertise for one owned route.
34
+ *
35
+ * The result is advisory: an adapter may accept unlisted model ids, and consumers must not turn
36
+ * absence into request rejection.
37
+ * @param _provider - one provider route owned by this adapter.
38
+ * @returns discoverable models in adapter-preferred order.
39
+ */
40
+ listModels(_provider) {
41
+ return Promise.resolve([]);
42
+ }
43
+ }
44
+ /**
45
+ * The `realtime` service: an adapter registry over the session vocabulary.
46
+ *
47
+ * Registration is effect-based, so HMR or a disposal unmounts routes with the contributing fiber —
48
+ * there is no separate teardown path that can be forgotten.
49
+ */
50
+ export class RealtimeRuntime extends Service {
51
+ adapters = new Map();
52
+ /**
53
+ * @param ctx - the Cordis context this service is mounted on.
54
+ */
55
+ constructor(ctx) {
56
+ super(ctx, 'realtime');
57
+ }
58
+ /**
59
+ * Register an adapter for the given provider routes, all-or-nothing.
60
+ *
61
+ * Disposed with the fiber. Throws `INVALID_PROVIDER` for a malformed route, `DUPLICATE_PROVIDER`
62
+ * if any route is already held by another registration.
63
+ * @param providers - every provider route this adapter should serve.
64
+ * @param adapter - the adapter that opens sessions for those routes.
65
+ * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.
66
+ */
67
+ registerAdapter(providers, adapter) {
68
+ if (providers.length === 0) {
69
+ throw new RealtimeError('an adapter must register at least one provider', REALTIME_ERROR_CODES.INVALID_PROVIDER);
70
+ }
71
+ // Routes this registration currently holds; `replace` rewrites it, and the disposer releases
72
+ // whatever it holds at disposal time.
73
+ const owned = new Set();
74
+ // `owned` being empty cannot report disposal on its own, because `replace([])` legally leaves a
75
+ // live registration holding none.
76
+ let released = false;
77
+ const dispose = this.ctx.effect(function* () {
78
+ this.commit(owned, this.prepare(providers, adapter, owned));
79
+ yield () => {
80
+ released = true;
81
+ for (const provider of owned)
82
+ this.adapters.delete(provider);
83
+ owned.clear();
84
+ };
85
+ }.bind(this), 'realtime.registerAdapter()');
86
+ const handle = (() => void dispose());
87
+ handle.replace = (next) => {
88
+ // Registering here would leak: the effect's disposer already ran, so nothing remains to
89
+ // release whatever this call would put in the map.
90
+ if (released) {
91
+ throw new RealtimeError('a disposed adapter registration cannot replace its routes', REALTIME_ERROR_CODES.REGISTRATION_DISPOSED);
92
+ }
93
+ this.commit(owned, this.prepare(next, adapter, owned));
94
+ };
95
+ return handle;
96
+ }
97
+ /**
98
+ * Validate one candidate route set for `adapter`, treating routes this registration already holds
99
+ * as available.
100
+ *
101
+ * Nothing is mutated: a rejected candidate leaves the registry exactly as it was, which is what
102
+ * makes {@link AdapterRegistrationHandle.replace} a swap rather than a delete-then-add that can
103
+ * strand the registry empty.
104
+ * @param providers - candidate routes.
105
+ * @param adapter - the adapter that would own them.
106
+ * @param owned - routes this same registration already holds.
107
+ * @returns registrations ready to commit.
108
+ */
109
+ prepare(providers, adapter, owned) {
110
+ const unique = new Set();
111
+ const registrations = [];
112
+ for (const provider of providers) {
113
+ if (typeof provider !== 'string' || provider.length === 0) {
114
+ throw new RealtimeError('adapter provider names must be non-empty strings', REALTIME_ERROR_CODES.INVALID_PROVIDER);
115
+ }
116
+ if (unique.has(provider) || (this.adapters.has(provider) && !owned.has(provider))) {
117
+ throw new RealtimeError(`an adapter for provider "${provider}" is already registered`, REALTIME_ERROR_CODES.DUPLICATE_PROVIDER);
118
+ }
119
+ const info = adapter.providerInfo(provider);
120
+ if (typeof info.id !== 'string' || info.id !== provider
121
+ || typeof info.name !== 'string' || info.name.length === 0) {
122
+ throw new RealtimeError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, REALTIME_ERROR_CODES.INVALID_PROVIDER);
123
+ }
124
+ unique.add(provider);
125
+ registrations.push({
126
+ adapter,
127
+ provider: info.description === undefined
128
+ ? { id: info.id, name: info.name }
129
+ : { id: info.id, name: info.name, description: info.description },
130
+ });
131
+ }
132
+ return registrations;
133
+ }
134
+ /**
135
+ * Swap this registration's routes for the prepared ones in one synchronous section, so no
136
+ * observer can see the registry between the release and the re-registration.
137
+ * @param owned - mutable set tracking which routes this registration holds.
138
+ * @param registrations - validated registrations to install.
139
+ */
140
+ commit(owned, registrations) {
141
+ for (const provider of owned)
142
+ this.adapters.delete(provider);
143
+ owned.clear();
144
+ for (const registration of registrations) {
145
+ this.adapters.set(registration.provider.id, registration);
146
+ owned.add(registration.provider.id);
147
+ }
148
+ }
149
+ /**
150
+ * Describe the provider routes that currently have an adapter.
151
+ * @returns detached provider metadata in registration order.
152
+ */
153
+ listProviders() {
154
+ return [...this.adapters.values()].map(({ provider }) => ({ ...provider }));
155
+ }
156
+ /**
157
+ * Resolve the adapter owning one route.
158
+ * @param provider - registered route to look up.
159
+ * @returns that route's registration.
160
+ * @throws RealtimeError `NO_ADAPTER` when the route is unregistered.
161
+ */
162
+ registration(provider) {
163
+ const registration = this.adapters.get(provider);
164
+ if (registration === undefined) {
165
+ throw new RealtimeError(`no realtime adapter registered for provider "${provider}"`, REALTIME_ERROR_CODES.NO_ADAPTER);
166
+ }
167
+ return registration;
168
+ }
169
+ /**
170
+ * Discover the voice models one registered route advertises.
171
+ * @param provider - registered route to inspect.
172
+ * @returns detached model metadata in adapter-preferred order, duplicates removed.
173
+ */
174
+ async listModels(provider) {
175
+ const models = await this.registration(provider).adapter.listModels(provider);
176
+ const seen = new Set();
177
+ const detached = [];
178
+ for (const model of models) {
179
+ if (typeof model.id !== 'string' || model.id.length === 0 || seen.has(model.id))
180
+ continue;
181
+ seen.add(model.id);
182
+ detached.push({
183
+ id: model.id,
184
+ name: typeof model.name === 'string' && model.name.length > 0 ? model.name : model.id,
185
+ ...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] },
186
+ ...model.outputModalities === undefined ? {} : { outputModalities: [...model.outputModalities] },
187
+ });
188
+ }
189
+ return detached;
190
+ }
191
+ /**
192
+ * Open one voice session through the adapter registered for its route.
193
+ *
194
+ * A field this seam cannot honor is rejected here rather than forwarded as a no-op — the caller
195
+ * learns the request is unsupported before a live conversation depends on it.
196
+ * @param options - the session request; `options.provider` selects the adapter.
197
+ * @returns the adapter's live session.
198
+ * @throws RealtimeError `NO_ADAPTER` for an unregistered route.
199
+ */
200
+ async session(options) {
201
+ if (typeof options.provider !== 'string' || options.provider.length === 0) {
202
+ throw new RealtimeError('a session needs a non-empty provider route', REALTIME_ERROR_CODES.INVALID_PROVIDER);
203
+ }
204
+ if (typeof options.model !== 'string' || options.model.length === 0) {
205
+ throw new RealtimeError('a session needs a non-empty model id', REALTIME_ERROR_CODES.INVALID_PROVIDER);
206
+ }
207
+ if (options.instructions !== undefined && options.instructions.length === 0) {
208
+ throw new RealtimeError('session instructions must be non-empty when supplied', REALTIME_ERROR_CODES.INVALID_APPEND);
209
+ }
210
+ if (options.signal?.aborted) {
211
+ throw new RealtimeError('session establishment was aborted before it started', REALTIME_ERROR_CODES.SESSION_CLOSED, {
212
+ cause: options.signal.reason,
213
+ });
214
+ }
215
+ return await this.registration(options.provider).adapter.session(options);
216
+ }
217
+ /**
218
+ * Validate one context append against the seam's bounds.
219
+ *
220
+ * Exposed so an adapter can enforce the same bound the seam promises, instead of each backend
221
+ * re-deriving it. Bound enforcement lives at the operation that makes the decision: a caller that
222
+ * bypasses this cannot silently send an over-long append.
223
+ * @param content - candidate append text.
224
+ * @param maxChars - character ceiling; defaults to the provider's documented bound.
225
+ * @returns the content unchanged, for convenient inline use.
226
+ * @throws RealtimeError `INVALID_APPEND` for a non-string, empty, or over-long value.
227
+ */
228
+ static assertAppendable(content, maxChars) {
229
+ if (typeof content !== 'string' || content.length === 0) {
230
+ throw new RealtimeError('an append needs non-empty content', REALTIME_ERROR_CODES.INVALID_APPEND);
231
+ }
232
+ if (content.length > maxChars) {
233
+ throw new RealtimeError(`an append of ${content.length} characters exceeds the ${maxChars}-character seam bound`, REALTIME_ERROR_CODES.INVALID_APPEND);
234
+ }
235
+ return content;
236
+ }
237
+ }
238
+ export default RealtimeRuntime;
239
+ //# sourceMappingURL=index.js.map
package/lib/types.d.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Types for the realtime voice seam. This module contains **no runtime code** — a
3
+ * DeepSeek Harness convention that keeps type-only imports erasable.
4
+ *
5
+ * @module dsh-realtime/types
6
+ */
7
+ /** A model route a realtime adapter can serve. */
8
+ export interface RealtimeProviderInfo {
9
+ /** Route id a caller passes as `RealtimeSessionOptions.provider`. Must equal the registered route. */
10
+ id: string;
11
+ /** Human-readable label for diagnostics and surfaces. */
12
+ name: string;
13
+ /** Optional one-line description of what this route speaks. */
14
+ description?: string;
15
+ }
16
+ /** One voice model advertised by an adapter. Catalog membership is advisory: it never gates a session. */
17
+ export interface RealtimeModelInfo {
18
+ /** Exact model id passed as `RealtimeSessionOptions.model`. */
19
+ id: string;
20
+ /** Human-readable label. */
21
+ name: string;
22
+ /** What the model can take in, e.g. `audio`, `text`, `image`. */
23
+ inputModalities?: readonly RealtimeModality[];
24
+ /** What the model puts out, e.g. `audio`, `text`. */
25
+ outputModalities?: readonly RealtimeModality[];
26
+ }
27
+ /** A modality a realtime model accepts or emits. */
28
+ export type RealtimeModality = 'audio' | 'text' | 'image';
29
+ /** Raw audio encoding expected on the wire. */
30
+ export interface RealtimeAudioFormat {
31
+ /** Sample rate in Hz. `24000` for GPT-Live-1. */
32
+ sampleRate: number;
33
+ /** Channel count. `1` (mono) for GPT-Live-1. */
34
+ channels: number;
35
+ /** Sample encoding. `pcm16` is signed little-endian 16-bit. */
36
+ encoding: 'pcm16';
37
+ }
38
+ /** What a caller asks an adapter to open. */
39
+ export interface RealtimeSessionOptions {
40
+ /** Registered route to use. Selects the adapter. */
41
+ provider: string;
42
+ /** Exact model id. */
43
+ model: string;
44
+ /**
45
+ * Opening system instructions.
46
+ *
47
+ * Advisory at the seam: the protocol may treat these as immutable once a session starts, in which
48
+ * case an adapter must surface a coded error rather than silently dropping the request. Extending
49
+ * live instructions is `RealtimeSession.appendInstructions`, not a new session.
50
+ */
51
+ instructions?: string;
52
+ /** Output voice id. Provider-specific; `undefined` leaves the provider default. */
53
+ voice?: string;
54
+ /** Cancellation for session establishment. Implementations must settle promptly after it aborts. */
55
+ signal?: AbortSignal;
56
+ /** Handler callbacks for the life of the session. */
57
+ handlers?: RealtimeSessionHandlers;
58
+ }
59
+ /** A delegated unit of work handed to the application by the voice model. */
60
+ export interface RealtimeDelegation {
61
+ /** Opaque correlation id. Preserve it unchanged on every update about this delegation. */
62
+ id: string;
63
+ /** Who is expected to do the work. `client` means this application. */
64
+ target: 'client' | 'responses';
65
+ /**
66
+ * Position on the session timeline, in milliseconds.
67
+ *
68
+ * Note there is deliberately **no task text** here: the protocol sends metadata only, so a
69
+ * consumer reconstructs intent from transcripts plus its own application state.
70
+ */
71
+ offsetMs: number;
72
+ }
73
+ /** A transcript fragment from either side of the conversation. */
74
+ export interface RealtimeTranscript {
75
+ /** Which side spoke. */
76
+ kind: 'input' | 'output';
77
+ /** The fragment text. Deltas concatenate in arrival order. */
78
+ text: string;
79
+ /** Whether this fragment closes the utterance. */
80
+ final: boolean;
81
+ }
82
+ /** Cumulative session usage. The protocol reports this in audio-seconds, not tokens. */
83
+ export interface RealtimeUsage {
84
+ /** Cumulative audio seconds billed for this session. */
85
+ seconds: number;
86
+ }
87
+ /** Callbacks an adapter invokes for session lifetime events. */
88
+ export interface RealtimeSessionHandlers {
89
+ /** The session is established and can accept audio. */
90
+ onReady?(info: RealtimeSessionStarted): void;
91
+ /** A transcript fragment arrived. */
92
+ onTranscript?(transcript: RealtimeTranscript): void;
93
+ /** The model delegated work to this application. */
94
+ onDelegation?(delegation: RealtimeDelegation): void;
95
+ /** Cumulative usage was updated. */
96
+ onUsage?(usage: RealtimeUsage): void;
97
+ /** Raw output audio, already decoded from the wire encoding. */
98
+ onAudio?(pcm16: Uint8Array): void;
99
+ /** The session ended. `reason` is provider-supplied when available. */
100
+ onClosed?(reason?: string): void;
101
+ /**
102
+ * A session-scoped failure the adapter contained rather than throwing.
103
+ *
104
+ * Adapters report here for failures that occur *after* the session opened; failures that prevent
105
+ * the session opening are thrown from `session()`.
106
+ */
107
+ onError?(error: Error): void;
108
+ }
109
+ /** Facts about an established session. */
110
+ export interface RealtimeSessionStarted {
111
+ /** The route that opened it. */
112
+ provider: string;
113
+ /** The model the provider accepted — which may differ from the requested id if the provider aliases. */
114
+ model: string;
115
+ /** The output voice the provider accepted. */
116
+ voice?: string;
117
+ /** Audio encoding the session expects for input. */
118
+ inputAudio: RealtimeAudioFormat;
119
+ /** Audio encoding the session emits. */
120
+ outputAudio: RealtimeAudioFormat;
121
+ }
122
+ /**
123
+ * A live voice session.
124
+ *
125
+ * Implementations own the transport. Callers own the conversation: they push audio in, receive
126
+ * callbacks, and answer delegations through the append methods.
127
+ */
128
+ export interface RealtimeSession {
129
+ /** Provider-assigned session identifier, for correlation in logs. */
130
+ readonly id: string;
131
+ /** Facts the provider accepted at startup. */
132
+ readonly started: RealtimeSessionStarted;
133
+ /**
134
+ * Append one frame of microphone audio.
135
+ *
136
+ * There is deliberately **no end-of-utterance call**: endpointing belongs to the provider, and a
137
+ * client-side detector would be a second, competing turn boundary. Frames are the only input.
138
+ * @param pcm16 - raw samples in the session's `inputAudio` format.
139
+ */
140
+ sendAudio(pcm16: Uint8Array): void;
141
+ /** Stop the provider consuming microphone audio, without ending the session. */
142
+ muteInput(): void;
143
+ /** Resume microphone consumption after {@link muteInput}. */
144
+ unmuteInput(): void;
145
+ /**
146
+ * Return a delegated result for the model to **speak aloud**.
147
+ * @param content - plain text, non-empty and within {@link MAX_APPEND_CHARS}.
148
+ * @param delegationId - the delegation this answers, or `undefined` for session-wide context.
149
+ */
150
+ appendCommentary(content: string, delegationId?: string): Promise<void>;
151
+ /**
152
+ * Add context the model may use **without speaking it** — progress, facts, intermediate state.
153
+ * @param content - plain text, non-empty and within {@link MAX_APPEND_CHARS}.
154
+ * @param delegationId - the delegation this relates to, or `undefined` for session-wide context.
155
+ */
156
+ appendThinking(content: string, delegationId?: string): Promise<void>;
157
+ /**
158
+ * Steer the live conversation's behaviour (tone, brevity, policy) without speaking anything.
159
+ * @param content - plain text, non-empty and within {@link MAX_APPEND_CHARS}.
160
+ * @param delegationId - `undefined` for session-wide steering.
161
+ */
162
+ appendInstructions(content: string, delegationId?: string): Promise<void>;
163
+ /**
164
+ * End the session and release the transport. Idempotent.
165
+ * @returns a promise settling once the transport is released.
166
+ */
167
+ close(): Promise<void>;
168
+ }
169
+ /**
170
+ * Upper bound, in characters, on one context append.
171
+ *
172
+ * The provider's real limit is **500 tokens**, which this seam cannot count without binding to a
173
+ * tokenizer it does not own. The character ceiling is therefore a deliberate conservative proxy:
174
+ * it is enforced here so that an over-long append fails locally with a coded error rather than
175
+ * halfway through a live conversation. Exact token accounting belongs to the adapter.
176
+ */
177
+ export declare const MAX_APPEND_CHARS = 2000;
178
+ //# sourceMappingURL=types.d.ts.map
package/lib/types.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Types for the realtime voice seam. This module contains **no runtime code** — a
3
+ * DeepSeek Harness convention that keeps type-only imports erasable.
4
+ *
5
+ * @module dsh-realtime/types
6
+ */
7
+ /**
8
+ * Upper bound, in characters, on one context append.
9
+ *
10
+ * The provider's real limit is **500 tokens**, which this seam cannot count without binding to a
11
+ * tokenizer it does not own. The character ceiling is therefore a deliberate conservative proxy:
12
+ * it is enforced here so that an over-long append fails locally with a coded error rather than
13
+ * halfway through a live conversation. Exact token accounting belongs to the adapter.
14
+ */
15
+ export const MAX_APPEND_CHARS = 2000;
16
+ //# sourceMappingURL=types.js.map
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "dsh-realtime",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Realtime voice capability seam for DeepSeek Harness: a provider registry plus the session adapter base.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./types": {
14
+ "types": "./lib/types.d.ts",
15
+ "default": "./lib/types.js"
16
+ },
17
+ "./error": {
18
+ "types": "./lib/error.d.ts",
19
+ "default": "./lib/error.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "lib/index.js",
25
+ "lib/index.d.ts",
26
+ "lib/types.js",
27
+ "lib/types.d.ts",
28
+ "lib/error.js",
29
+ "lib/error.d.ts"
30
+ ],
31
+ "license": "MIT",
32
+ "keywords": [
33
+ "dsh",
34
+ "dsh-plugin",
35
+ "deepseek-harness",
36
+ "cordis",
37
+ "realtime",
38
+ "voice",
39
+ "speech",
40
+ "seam"
41
+ ],
42
+ "homepage": "https://github.com/Travizm/dsh-openai-live",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/Travizm/dsh-openai-live.git",
46
+ "directory": "packages/realtime"
47
+ },
48
+ "engines": {
49
+ "node": "^22.19.0 || >=24.0.0"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public",
53
+ "registry": "https://registry.npmjs.org"
54
+ },
55
+ "peerDependencies": {
56
+ "@deepseek-ai/cordis": "^4.0.3"
57
+ },
58
+ "devDependencies": {
59
+ "@deepseek-ai/cordis": "^4.0.4"
60
+ },
61
+ "scripts": {
62
+ "build": "tsc -b",
63
+ "typecheck": "tsc -b --dry"
64
+ }
6
65
  }