@byokit/realtime 0.1.0 → 0.2.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/CHANGELOG.md +19 -0
- package/README.md +166 -0
- package/dist/adapter.d.ts +5 -0
- package/dist/auth.d.ts +13 -0
- package/dist/auth.js +29 -0
- package/dist/chatgpt.js +10 -14
- package/dist/child.js +33 -10
- package/dist/client.js +55 -9
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node.d.ts +14 -1
- package/dist/node.js +63 -15
- package/dist/prose.d.ts +2 -0
- package/dist/prose.js +14 -0
- package/dist/tools.d.ts +9 -2
- package/dist/tools.js +30 -2
- package/dist/types.d.ts +4 -0
- package/dist/webrtc.js +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.2.0 (2026-09-30)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
- FIX: Make each realtime README example independently typecheck against the package exports.
|
|
10
|
+
- FIX: Start phone WebRTC with React Native AbortSignal implementations that have no throwIfAborted method.
|
|
11
|
+
- FIX: Report connected when playback has nothing to drain, and flush queued speech after audio clears while muted.
|
|
12
|
+
- FIX: Offer opt-in PCM capture and playback-tail retention across carrier reconnects, releasing retained media on stop.
|
|
13
|
+
- FIX: Apply app-supplied hangup policy on every route and end the session after the next agent transcript.
|
|
14
|
+
- FIX: Use the host tool watchdog and failure wording instead of a competing fixed 300-second child deadline.
|
|
15
|
+
- FIX: Keep ChatGPT delegations and retransmission results across ordinary user turns; explicit interruption and close still cancel them.
|
|
16
|
+
- FIX: Ignore empty ChatGPT transcripts instead of emitting a failure line or clearing audio.
|
|
17
|
+
- FIX: Drain child output before reporting process disconnection so the provider's final close reason survives.
|
|
18
|
+
- FIX: Preserve transcripts up to 4,000 UTF-8 bytes and bound state details by bytes without splitting Unicode characters.
|
|
19
|
+
- FIX: Preserve sanitized credential-error remedies and accept app-supplied auth failure wording.
|
|
20
|
+
- FIX: Overlap ChatGPT media preparation, child startup and access lookup, keep early offers behind credential configuration, and allow explicit app signaling identity.
|
|
21
|
+
- FIX: Route semantic app tool replies to the host bridge and cancel pending app requests when the session closes.
|
|
22
|
+
- Add structured delegation through app-supplied tools, preserving named targets and operation ids.
|
|
23
|
+
- Add a bounded, read-only advisory auth check that never gates session startup or initiates login.
|
|
5
24
|
- Add interruption fencing and tool cancellation, bounded provider and transport reconnects, and fresh-call ChatGPT subscription voice interruption.
|
|
6
25
|
|
|
7
26
|
## 0.1.0 (2026-09-30)
|
package/README.md
CHANGED
|
@@ -32,10 +32,176 @@ const client = realtimeClient({
|
|
|
32
32
|
|
|
33
33
|
For ChatGPT voice register a `delegate` tool handler receiving `{ request }` and supply the app's planner. API routes use `auth: { kind: 'key', key }`, with billing per use. The subscription route uses the person's ChatGPT sign-in from `@byokit/accounts`; it never reads Codex credentials or starts the Codex CLI. Backend refusal is reported as `not-included`.
|
|
34
34
|
|
|
35
|
+
## Structured delegation and named targets
|
|
36
|
+
|
|
37
|
+
`delegationHandler` dispatches a JSON request of the form `{ name, arguments }`
|
|
38
|
+
directly through an app-supplied `toolBridge`. For ordinary spoken requests it
|
|
39
|
+
uses the optional app planner. Invalid structured requests never enter the planner.
|
|
40
|
+
Keep the action bridge separate from the outer `delegate` bridge:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { delegationHandler, toolBridge } from '@byokit/realtime/node';
|
|
44
|
+
import type { ToolHandler } from '@byokit/realtime/node';
|
|
45
|
+
import type { RealtimeHostFrame } from '@byokit/realtime';
|
|
46
|
+
|
|
47
|
+
declare const emit: (frame: RealtimeHostFrame) => void;
|
|
48
|
+
declare const sendAuthorizedMessage: ToolHandler;
|
|
49
|
+
declare const plan: (request: string, context: Parameters<ToolHandler>[1]) => Promise<string>;
|
|
50
|
+
const actionTools = [{ name: 'message', description: 'Message a named agent', parameters: {
|
|
51
|
+
type: 'object', properties: { agent: { type: 'string' }, text: { type: 'string' } },
|
|
52
|
+
required: ['agent', 'text'], additionalProperties: false,
|
|
53
|
+
} }];
|
|
54
|
+
const actions = toolBridge({ tools: actionTools, handlers: { message: sendAuthorizedMessage },
|
|
55
|
+
emit, failure: () => 'The message could not be confirmed.', timeoutFor: () => 75000 });
|
|
56
|
+
const delegate = delegationHandler({ bridge: actions, plan });
|
|
57
|
+
const tools = [{ name: 'delegate', description: 'Delegate a spoken request', parameters: { type: 'object' } }];
|
|
58
|
+
const bridge = toolBridge({ tools, handlers: { delegate }, emit,
|
|
59
|
+
timeoutFor: () => 340000, failure: () => 'The request could not be confirmed.' });
|
|
60
|
+
// Pass the outer bridge and its delegate tool to realtimeEngine.
|
|
61
|
+
// A structured request can be:
|
|
62
|
+
// {"name":"message","arguments":{"agent":"Avery","text":"Please report progress"}}
|
|
63
|
+
// Close actions as well as the outer bridge when the session ends.
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The kit preserves arguments and operation ids; retries with the same call id
|
|
67
|
+
share a result. Named targets, target lookup, ambiguity checks, authorization,
|
|
68
|
+
argument validation and mutation receipts belong to the app's handlers. Tool
|
|
69
|
+
JSON schemas describe the provider catalog; they are not runtime authorization.
|
|
70
|
+
The app planner owns its model, conversation memory, tool budget and deadline;
|
|
71
|
+
the kit does not add a planner model or any agent runtime dependency. Aborted
|
|
72
|
+
calls must stop in the app too; completed side effects are never rolled back.
|
|
73
|
+
The host bridge owns every tool watchdog and failure message; the child has no
|
|
74
|
+
second deadline. A 340-second planner or a 290-second watch can therefore use
|
|
75
|
+
the app's declared budget. An ordinary ChatGPT user turn preserves unfinished
|
|
76
|
+
delegations and their retransmission cache. Explicit `interrupt` or close aborts
|
|
77
|
+
them; a new turn id still identifies a new request.
|
|
78
|
+
|
|
79
|
+
## Advisory auth check
|
|
80
|
+
|
|
81
|
+
Pass a read-only saved-sign-in lookup as `authCheck` on the engine. It starts
|
|
82
|
+
independently, is never awaited by startup, and cannot close the session. It
|
|
83
|
+
does not call `auth.access`, refresh a token, run a CLI or initiate login.
|
|
84
|
+
The app's `peek` callback must also never refresh, prompt or start login.
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { realtimeEngine, toolBridge } from '@byokit/realtime/node';
|
|
88
|
+
import type { RealtimeAuth, RealtimeHostFrame, RealtimeTool } from '@byokit/realtime';
|
|
89
|
+
|
|
90
|
+
declare const auth: Extract<RealtimeAuth, { kind: 'plan' }>;
|
|
91
|
+
declare const tools: RealtimeTool[];
|
|
92
|
+
declare const bridge: ReturnType<typeof toolBridge>;
|
|
93
|
+
declare const emit: (frame: RealtimeHostFrame) => void;
|
|
94
|
+
declare const peekSavedSignIn: (signal: AbortSignal) => Promise<boolean>;
|
|
95
|
+
declare const updateSettingsBadge: (status: 'ready' | 'signed-out' | 'unknown') => void;
|
|
96
|
+
const engine = realtimeEngine({
|
|
97
|
+
engine: 'chatgpt', auth,
|
|
98
|
+
tools, bridge, emit,
|
|
99
|
+
authCheck: { peek: peekSavedSignIn, onStatus: status => updateSettingsBadge(status) },
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`ready` means the app reports a saved sign-in, not provider entitlement.
|
|
104
|
+
`signed-out` means the lookup returned false. Errors or a timeout produce
|
|
105
|
+
`unknown`; the default timeout is one second, capped at ten seconds. Close
|
|
106
|
+
cancels pending checks and suppresses late notifications. Normal session
|
|
107
|
+
authentication still needs the credential from `auth.access`.
|
|
108
|
+
For a standalone settings lookup, `realtimeAuthCheck({ peek })` is also exported
|
|
109
|
+
from the portable entry and returns `{ result, close }`. Do not await this check
|
|
110
|
+
before opening a voice session or show a login prompt from its result.
|
|
111
|
+
|
|
112
|
+
## Lifecycle announcements and semantic app tools
|
|
113
|
+
|
|
114
|
+
Feed app-confirmed lifecycle reports into the existing speech path. The kit
|
|
115
|
+
does not watch agents or infer their outcome:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import type { realtimeClient } from '@byokit/realtime';
|
|
119
|
+
import type { realtimeEngine } from '@byokit/realtime/node';
|
|
120
|
+
declare const client: ReturnType<typeof realtimeClient>;
|
|
121
|
+
declare const engine: ReturnType<typeof realtimeEngine>;
|
|
122
|
+
|
|
123
|
+
// Called by the app's lifecycle subscription; use bounded, sanitized app wording.
|
|
124
|
+
client.speak('Avery has finished the task.');
|
|
125
|
+
// A host can inject the same report directly:
|
|
126
|
+
engine.receive({ type: 'realtime.say', text: 'Avery has finished the task.' });
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`speak` queues at most sixteen reports until the call is ready, waits for local
|
|
130
|
+
playback drain and does not replay sent speech on reconnect. `onTurn` observes
|
|
131
|
+
transcripts and `onStatus` observes playback states; a transcript alone does
|
|
132
|
+
not prove the report was audible. The app owns push delivery, deduplication,
|
|
133
|
+
waking a sleeping call and stopping it after reports.
|
|
134
|
+
When `player.finish(callback)` returns false, the client reports connected
|
|
135
|
+
immediately. True means the player will call back after draining. Clearing
|
|
136
|
+
audio also rechecks queued speech, including while the microphone is muted.
|
|
137
|
+
|
|
138
|
+
Compose `appBridge` with `toolBridge` to round-trip semantic phone requests:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { appBridge, toolBridge } from '@byokit/realtime/node';
|
|
142
|
+
import type { RealtimeHostFrame } from '@byokit/realtime';
|
|
143
|
+
declare const emit: (frame: RealtimeHostFrame) => void;
|
|
144
|
+
const app = appBridge(emit);
|
|
145
|
+
const navigation = [{ name: 'navigate', description: 'Open an app destination',
|
|
146
|
+
parameters: { type: 'object', properties: { target: { type: 'string' } }, required: ['target'] } }];
|
|
147
|
+
const bridge = toolBridge({ tools: navigation, app, emit,
|
|
148
|
+
handlers: { navigate: (args, { signal }) => app.run('navigate', String(args.target), signal) },
|
|
149
|
+
failure: () => 'The app could not complete that request.',
|
|
150
|
+
});
|
|
151
|
+
// client.onAppRequest handles view/navigate/activate; forward all client frames
|
|
152
|
+
// to engine.receive, which routes app results back to this app bridge.
|
|
153
|
+
// Closing the engine also cancels pending app requests.
|
|
154
|
+
```
|
|
155
|
+
|
|
35
156
|
The main entry is portable for web and React Native and loads no native code. `./webrtc` supplies `webRtcPeer` for browsers and selects its native implementation under the `react-native` condition; install the optional `react-native-webrtc` 124.0.8 peer in the RN app. All media APIs can be injected for tests. `./node` requires Node 22.18+ and is for the credential host. It starts its own adapter child with an empty environment; credentials travel over private stdin. No provider key or plan token is sent to the device.
|
|
36
157
|
|
|
37
158
|
`AudioPorts` requires microphone acquisition/release, PCM capture, playback admission/drain and audio routing. The app owns permissions and its microphone service. Acquire must resolve only after that service is ready. The client awaits it before capture. Each client owns its ports; the app must arbitrate shared microphones.
|
|
38
159
|
|
|
39
160
|
Close the client and engine when the call ends. `client.interrupt()` clears playback and aborts unfinished tools; old response output is fenced. Transient provider and transport failures retry twice, after 500 and 1,000 ms, with a fresh budget after 30 healthy seconds. Sent audio, speech and tool calls are never replayed. The app can mark transport closure terminal with `retryableClose`. Explicit ChatGPT interruption opens a fresh call; explicit Gemini interruption rotates its provider session. Native speech interruption remains provider-driven. No STT/LLM/TTS chain is exported. See [the lift contract](../../docs/realtime-kit.md) for bounds, events and usage.
|
|
40
161
|
|
|
162
|
+
## Reconnect and host policy
|
|
163
|
+
|
|
164
|
+
Set `preserveMediaOnReconnect: true` on the client to retain a ready PCM
|
|
165
|
+
recorder, microphone lease, audio route and playback tail across a carrier
|
|
166
|
+
reconnect. Audio captured while the carrier is down is discarded. Pending
|
|
167
|
+
microphone acquisition still releases before retry; WebRTC always creates a
|
|
168
|
+
fresh peer for a fresh call. A changed PCM format releases the old recorder
|
|
169
|
+
before acquiring another. Stop releases retained media even during backoff.
|
|
170
|
+
The default remains release/clear/reacquire.
|
|
171
|
+
|
|
172
|
+
The engine accepts app phrase policy and credential-error wording:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import { realtimeEngine, toolBridge } from '@byokit/realtime/node';
|
|
176
|
+
import type { RealtimeAuth, RealtimeHostFrame, RealtimeTool } from '@byokit/realtime';
|
|
177
|
+
declare const auth: Extract<RealtimeAuth, { kind: 'plan' }>;
|
|
178
|
+
declare const tools: RealtimeTool[];
|
|
179
|
+
declare const bridge: ReturnType<typeof toolBridge>;
|
|
180
|
+
declare const emit: (frame: RealtimeHostFrame) => void;
|
|
181
|
+
|
|
182
|
+
const engine = realtimeEngine({ engine: 'chatgpt',
|
|
183
|
+
auth, tools, bridge, emit,
|
|
184
|
+
hangup: text => /^goodbye[.!]?$/i.test(text.trim()),
|
|
185
|
+
authFailure: () => 'Sign in again from Settings.',
|
|
186
|
+
signalingIdentity: { originator: 'voice-app', userAgent: 'voice-app/1' },
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Hangup ends after the next completed agent transcript so the provider can
|
|
191
|
+
give a farewell. It is a transcript-level policy, not proof that playback
|
|
192
|
+
drained. Credential errors are sanitized, bounded and emitted as the close
|
|
193
|
+
reason; the kit initiates no login or prompt. Transcripts preserve up to
|
|
194
|
+
4,000 UTF-8 bytes, state details up to 500 bytes, and close reasons up to
|
|
195
|
+
2,048 bytes. Empty ChatGPT transcripts are ignored.
|
|
196
|
+
|
|
197
|
+
ChatGPT emits `realtime.webrtc.start` while the isolated child starts and
|
|
198
|
+
access is being resolved. Device frames remain bounded and queued until the
|
|
199
|
+
credential/config line is sent. This overlaps local setup without sending a
|
|
200
|
+
signaling request before credentials are available. `signalingIdentity`
|
|
201
|
+
lets the app name its own originator/user-agent; the default for both is
|
|
202
|
+
`byokit`, and identity values must be printable ASCII of at most 160 characters.
|
|
203
|
+
Credentials, endpoint restrictions and redirect rejection are unchanged.
|
|
204
|
+
The identity option allows a consumer to compare signaling latency; offline
|
|
205
|
+
tests prove startup ordering, not a reduction in the remote provider's POST time.
|
|
206
|
+
|
|
41
207
|
For an explicitly live microphone and subscription-provider proof, run [the bounded voice demo](../../examples/realtime-voice/README.md). It signs in through accounts, keeps credentials only in memory, and records connection facts and turn counts. Offline tests do not establish a live pass.
|
package/dist/adapter.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { toolBridge } from './tools.ts';
|
|
2
2
|
import type { RealtimeTool } from './types.ts';
|
|
3
|
+
export type RealtimeSignalingIdentity = {
|
|
4
|
+
originator?: string;
|
|
5
|
+
userAgent?: string;
|
|
6
|
+
};
|
|
3
7
|
export type AdapterOptions = {
|
|
4
8
|
key: string;
|
|
5
9
|
instructions: string;
|
|
@@ -10,4 +14,5 @@ export type AdapterOptions = {
|
|
|
10
14
|
endpoint?: string;
|
|
11
15
|
redact?: RegExp[];
|
|
12
16
|
hangup?: (text: string) => boolean;
|
|
17
|
+
signalingIdentity?: RealtimeSignalingIdentity;
|
|
13
18
|
};
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type RealtimeAuthStatus = 'ready' | 'signed-out' | 'unknown';
|
|
2
|
+
export type RealtimeAuthCheckOptions = {
|
|
3
|
+
/** Read-only saved-sign-in check. Must never start login, refresh access or prompt. */
|
|
4
|
+
peek(signal: AbortSignal): Promise<boolean>;
|
|
5
|
+
onStatus?(status: RealtimeAuthStatus): void;
|
|
6
|
+
/** Defaults to 1 second; capped at 10 seconds. */
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
};
|
|
9
|
+
/** Advisory only: session startup must proceed independently of this check. */
|
|
10
|
+
export declare function realtimeAuthCheck(options: RealtimeAuthCheckOptions): {
|
|
11
|
+
result: Promise<RealtimeAuthStatus>;
|
|
12
|
+
close(): void;
|
|
13
|
+
};
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Advisory only: session startup must proceed independently of this check. */
|
|
2
|
+
export function realtimeAuthCheck(options) {
|
|
3
|
+
const controller = new AbortController();
|
|
4
|
+
let settled = false;
|
|
5
|
+
let resolve;
|
|
6
|
+
const result = new Promise(done => { resolve = done; });
|
|
7
|
+
const finish = (status, notify = true) => {
|
|
8
|
+
if (settled)
|
|
9
|
+
return;
|
|
10
|
+
settled = true;
|
|
11
|
+
clearTimeout(timer);
|
|
12
|
+
controller.abort();
|
|
13
|
+
resolve(status);
|
|
14
|
+
// A settings observer cannot fail a call or create an unhandled rejection.
|
|
15
|
+
if (notify) {
|
|
16
|
+
try {
|
|
17
|
+
options.onStatus?.(status);
|
|
18
|
+
}
|
|
19
|
+
catch { }
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const timeout = options.timeoutMs ?? 1000;
|
|
23
|
+
const timer = setTimeout(() => finish('unknown'), Number.isFinite(timeout) ? Math.min(10000, Math.max(1, timeout)) : 1000);
|
|
24
|
+
void Promise.resolve().then(() => {
|
|
25
|
+
if (!settled)
|
|
26
|
+
return options.peek(controller.signal);
|
|
27
|
+
}).then(value => finish(value === true ? 'ready' : value === false ? 'signed-out' : 'unknown'), () => finish('unknown'));
|
|
28
|
+
return { result, close() { finish('unknown', false); } };
|
|
29
|
+
}
|
package/dist/chatgpt.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { cleanProse } from "./prose.js";
|
|
1
|
+
import { cleanProse, cleanProseBytes } from "./prose.js";
|
|
3
2
|
import { MAX_REALTIME_SDP_BYTES } from "./frames.js";
|
|
4
3
|
export const CHATGPT_SIGNALING_URL = 'https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas';
|
|
5
4
|
function emit(frame) { process.stdout.write(`${JSON.stringify(frame)}\n`); }
|
|
@@ -36,7 +35,6 @@ export async function boundedBody(response, max = MAX_REALTIME_SDP_BYTES) {
|
|
|
36
35
|
return new TextDecoder().decode(bytes);
|
|
37
36
|
}
|
|
38
37
|
export function createChatgptAdapter(options) {
|
|
39
|
-
const fence = responseFence();
|
|
40
38
|
const lifetime = new AbortController();
|
|
41
39
|
let offered = false, closed = false;
|
|
42
40
|
const safe = (text) => cleanProse(text, 'Voice could not complete that request.', 4000, options.redact);
|
|
@@ -57,7 +55,7 @@ export function createChatgptAdapter(options) {
|
|
|
57
55
|
send({ type: id ? 'delegation.context.append' : 'session.context.append', ...(id ? { delegation_item_id: id } : {}), channel: 'speakable', content: [{ type: 'input_text', text }] });
|
|
58
56
|
};
|
|
59
57
|
const close = (reason = 'Voice ended.', retryable = false) => { if (closed)
|
|
60
|
-
return; closed = true;
|
|
58
|
+
return; closed = true; lifetime.abort(); options.bridge.close(); emit({ type: 'realtime.closed', reason: cleanProseBytes(reason, 'Voice ended.', 2048, options.redact), ...(retryable ? { retryable: true } : {}) }); };
|
|
61
59
|
const signalOffer = async (sdp) => {
|
|
62
60
|
if (offered)
|
|
63
61
|
throw new Error('Duplicate voice offer.');
|
|
@@ -68,7 +66,7 @@ export function createChatgptAdapter(options) {
|
|
|
68
66
|
throw new Error('Unsupported voice signaling origin.');
|
|
69
67
|
const response = await fetch(url, {
|
|
70
68
|
method: 'POST', redirect: 'error', signal: AbortSignal.any([lifetime.signal, AbortSignal.timeout(20000)]),
|
|
71
|
-
headers: { Authorization: `Bearer ${options.key}`, 'chatgpt-account-id': options.accountId, originator: 'byokit', 'User-Agent': 'byokit', 'OpenAI-Alpha': 'quicksilver=v2', 'content-type': 'application/json' },
|
|
69
|
+
headers: { Authorization: `Bearer ${options.key}`, 'chatgpt-account-id': options.accountId, originator: options.signalingIdentity?.originator ?? 'byokit', 'User-Agent': options.signalingIdentity?.userAgent ?? 'byokit', 'OpenAI-Alpha': 'quicksilver=v2', 'content-type': 'application/json' },
|
|
72
70
|
body: JSON.stringify({ sdp, session: { model: options.model ?? 'gpt-live-1-codex', instructions: options.instructions, audio: { output: { voice: options.voice ?? 'sol' } }, delegation: { type: 'client', ack_filler: false } } }),
|
|
73
71
|
});
|
|
74
72
|
const answer = await boundedBody(response);
|
|
@@ -85,13 +83,13 @@ export function createChatgptAdapter(options) {
|
|
|
85
83
|
// Provider event vocabulary stays inside the credential child.
|
|
86
84
|
const event = JSON.parse(raw);
|
|
87
85
|
if (event.type === 'turn.done' && typeof event.turn?.transcript === 'string') {
|
|
86
|
+
const text = cleanProseBytes(event.turn.transcript, '', 4000, options.redact);
|
|
87
|
+
if (!text)
|
|
88
|
+
return;
|
|
88
89
|
const role = event.turn.role === 'assistant' ? 'agent' : 'user';
|
|
89
|
-
if (role === 'user')
|
|
90
|
-
fence.begin();
|
|
91
|
-
turns.clear();
|
|
90
|
+
if (role === 'user')
|
|
92
91
|
emit({ type: 'realtime.audio.clear' });
|
|
93
|
-
}
|
|
94
|
-
emit({ type: 'realtime.transcript', role, text: safe(event.turn.transcript) });
|
|
92
|
+
emit({ type: 'realtime.transcript', role, text });
|
|
95
93
|
if (role === 'agent')
|
|
96
94
|
options.bridge.answered();
|
|
97
95
|
options.bridge.state(role === 'agent' ? 'connected' : 'thinking');
|
|
@@ -109,20 +107,18 @@ export function createChatgptAdapter(options) {
|
|
|
109
107
|
return;
|
|
110
108
|
const turn = event.item.user_bidi_turn_id;
|
|
111
109
|
const share = typeof turn === 'string' && turn.trim() && turn.length <= 128 ? JSON.stringify([turn.trim(), request.trim()]) : undefined;
|
|
112
|
-
const signal = fence.signal;
|
|
113
110
|
let promise = share ? turns.get(share) : undefined;
|
|
114
111
|
if (!promise) {
|
|
115
|
-
promise = options.bridge.run('delegate', { request }, `codex:${id}`,
|
|
112
|
+
promise = options.bridge.run('delegate', { request }, `codex:${id}`, lifetime.signal);
|
|
116
113
|
if (share)
|
|
117
114
|
turns.set(share, promise);
|
|
118
115
|
}
|
|
119
116
|
delegations.set(id, promise);
|
|
120
117
|
const result = await promise;
|
|
121
|
-
if (!closed
|
|
118
|
+
if (!closed)
|
|
122
119
|
append(result, id);
|
|
123
120
|
}
|
|
124
121
|
};
|
|
125
|
-
emit({ type: 'realtime.webrtc.start', dataChannelLabel: 'oai-events' });
|
|
126
122
|
return {
|
|
127
123
|
receive(frame) {
|
|
128
124
|
if (closed)
|
package/dist/child.js
CHANGED
|
@@ -7,7 +7,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
9
|
import { createInterface } from 'node:readline';
|
|
10
|
-
import {
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
11
|
import { parseRealtimeClientFrame } from "./frames.js";
|
|
12
12
|
import { createChatgptAdapter } from "./chatgpt.js";
|
|
13
13
|
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
@@ -20,14 +20,36 @@ input.on('line', line => {
|
|
|
20
20
|
if (!starting) {
|
|
21
21
|
const config = JSON.parse(line);
|
|
22
22
|
starting = (async () => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
// The parent is the single authority for tool bounds, watchdogs, failure
|
|
24
|
+
// wording and answer state. This proxy adds no competing deadline.
|
|
25
|
+
const bridge = {
|
|
26
|
+
run(name, args = {}, id = randomUUID(), signal) {
|
|
27
|
+
if (signal?.aborted)
|
|
28
|
+
return Promise.resolve('The request was cancelled.');
|
|
29
|
+
if (!config.tools.some(tool => tool.name === name) || !args || typeof args !== 'object' || Array.isArray(args)
|
|
30
|
+
|| typeof id !== 'string' || id.length > 160 || Buffer.byteLength(JSON.stringify(args)) > 16000)
|
|
31
|
+
return Promise.resolve('That request is invalid.');
|
|
32
|
+
const key = JSON.stringify([name, args]);
|
|
33
|
+
const prior = pending.get(id);
|
|
34
|
+
if (prior)
|
|
35
|
+
return prior.key === key ? prior.promise : Promise.resolve('Conflicting repeated request. No additional action was performed.');
|
|
36
|
+
if (pending.size >= 8)
|
|
37
|
+
return Promise.resolve('Requests are at the session limit.');
|
|
38
|
+
let finish;
|
|
39
|
+
const cancel = () => { process.stdout.write(`${JSON.stringify({ type: 'kit.tool.cancel', id })}\n`); finish('The request was cancelled.'); };
|
|
40
|
+
const promise = new Promise(resolve => {
|
|
41
|
+
finish = output => { signal?.removeEventListener('abort', cancel); pending.delete(id); resolve(output); };
|
|
42
|
+
});
|
|
43
|
+
pending.set(id, { key, promise, finish, cancel });
|
|
44
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
45
|
+
process.stdout.write(`${JSON.stringify({ type: 'kit.tool', name, args, id })}\n`);
|
|
46
|
+
return promise;
|
|
47
|
+
},
|
|
48
|
+
state(state, detail) { emit({ type: 'realtime.state', state, ...(detail ? { detail } : {}) }); },
|
|
49
|
+
answered() { }, receive: () => false,
|
|
50
|
+
close() { for (const call of [...pending.values()])
|
|
51
|
+
call.cancel(); },
|
|
52
|
+
};
|
|
31
53
|
const options = { ...config, bridge, redact: config.redact?.map(pattern => new RegExp(pattern.source, pattern.flags)) };
|
|
32
54
|
if (config.engine === 'chatgpt')
|
|
33
55
|
adapter = createChatgptAdapter({ ...options, accountId: config.accountId ?? '' });
|
|
@@ -35,6 +57,7 @@ input.on('line', line => {
|
|
|
35
57
|
const module = await import(__rewriteRelativeImportExtension(`./providers/${config.engine}.${import.meta.url.endsWith('.ts') ? 'ts' : 'js'}`));
|
|
36
58
|
adapter = module.createAdapter(options);
|
|
37
59
|
}
|
|
60
|
+
process.stdout.write(`${JSON.stringify({ type: 'kit.ready' })}\n`);
|
|
38
61
|
})();
|
|
39
62
|
await starting;
|
|
40
63
|
return;
|
|
@@ -42,7 +65,7 @@ input.on('line', line => {
|
|
|
42
65
|
await starting;
|
|
43
66
|
const frame = JSON.parse(line);
|
|
44
67
|
if (frame.type === 'kit.tool.result')
|
|
45
|
-
pending.get(frame.id)?.(String(frame.output));
|
|
68
|
+
pending.get(frame.id)?.finish(String(frame.output));
|
|
46
69
|
else
|
|
47
70
|
adapter?.receive(parseRealtimeClientFrame(frame));
|
|
48
71
|
})().catch(() => { emit({ type: 'realtime.closed', reason: 'Voice session could not start.' }); process.exit(1); });
|
package/dist/client.js
CHANGED
|
@@ -5,6 +5,7 @@ export function realtimeClient(options) {
|
|
|
5
5
|
let stopped = false, muted = false, epoch = 0, reconnects = 0;
|
|
6
6
|
let retry, reconnectTimer, stableTimer;
|
|
7
7
|
let attempt;
|
|
8
|
+
let retained;
|
|
8
9
|
const mic = [], speech = [];
|
|
9
10
|
let micBytes = 0;
|
|
10
11
|
const stats = { micCaptured: 0, micSent: 0, micQueued: 0, micDropped: 0, transportReconnects: 0, providerReconnects: 0 };
|
|
@@ -29,7 +30,8 @@ export function realtimeClient(options) {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
};
|
|
32
|
-
const reset = () => { clearTimeout(retry); retry = undefined; clearTimeout(stableTimer);
|
|
33
|
+
const reset = (clearPlayback = true) => { clearTimeout(retry); retry = undefined; clearTimeout(stableTimer); if (clearPlayback)
|
|
34
|
+
audio.player.clear(); mic.length = 0; micBytes = 0; };
|
|
33
35
|
const stop = (reason) => {
|
|
34
36
|
if (stopped)
|
|
35
37
|
return;
|
|
@@ -37,8 +39,9 @@ export function realtimeClient(options) {
|
|
|
37
39
|
epoch++;
|
|
38
40
|
clearTimeout(reconnectTimer);
|
|
39
41
|
reset();
|
|
40
|
-
const current = attempt;
|
|
42
|
+
const current = attempt ?? retained;
|
|
41
43
|
attempt = undefined;
|
|
44
|
+
retained = undefined;
|
|
42
45
|
current?.stream?.send({ type: 'realtime.control', action: 'stop' });
|
|
43
46
|
void cleanup(current).catch(() => { });
|
|
44
47
|
audio.player.stop();
|
|
@@ -84,10 +87,19 @@ export function realtimeClient(options) {
|
|
|
84
87
|
}
|
|
85
88
|
attempt = undefined;
|
|
86
89
|
epoch++;
|
|
87
|
-
reset();
|
|
90
|
+
reset(!options.preserveMediaOnReconnect);
|
|
88
91
|
reconnects++;
|
|
89
92
|
stats.transportReconnects++;
|
|
90
93
|
options.onStatus('connecting', reason);
|
|
94
|
+
if (options.preserveMediaOnReconnect && current.capture && !current.peer) {
|
|
95
|
+
retained = current;
|
|
96
|
+
current.signal.abort();
|
|
97
|
+
current.stream?.close();
|
|
98
|
+
if (current.stream)
|
|
99
|
+
audio.player.unbind(current.stream);
|
|
100
|
+
reconnectTimer = setTimeout(connect, reconnects * 500);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
91
103
|
// A slow microphone release cannot overlap the next acquisition.
|
|
92
104
|
void cleanup(current).then(() => {
|
|
93
105
|
if (!stopped)
|
|
@@ -98,12 +110,17 @@ export function realtimeClient(options) {
|
|
|
98
110
|
if (stopped)
|
|
99
111
|
return;
|
|
100
112
|
reconnectTimer = undefined;
|
|
101
|
-
const current = { acquired: false, ready: false, signal: new AbortController() };
|
|
113
|
+
const current = { ...retained, stream: undefined, acquired: retained?.acquired ?? false, ready: false, signal: new AbortController() };
|
|
114
|
+
retained = undefined;
|
|
102
115
|
attempt = current;
|
|
103
116
|
const generation = ++epoch;
|
|
104
117
|
const active = () => !stopped && attempt === current && epoch === generation;
|
|
105
118
|
const onAudio = (data) => {
|
|
106
|
-
|
|
119
|
+
// A retained recorder's callback follows its capture handle into the new carrier.
|
|
120
|
+
const captureActive = options.preserveMediaOnReconnect && current.capture
|
|
121
|
+
? !stopped && attempt?.capture === current.capture && attempt.ready
|
|
122
|
+
: active();
|
|
123
|
+
if (!captureActive || muted)
|
|
107
124
|
return;
|
|
108
125
|
try {
|
|
109
126
|
const bytes = realtimePcm16ByteLength(data);
|
|
@@ -131,6 +148,28 @@ export function realtimeClient(options) {
|
|
|
131
148
|
options.onActivity?.();
|
|
132
149
|
switch (frame.type) {
|
|
133
150
|
case 'realtime.ready':
|
|
151
|
+
if (current.capture && (current.inputRate !== frame.inputRate || current.outputRate !== frame.outputRate)) {
|
|
152
|
+
const capture = current.capture, acquired = current.acquired;
|
|
153
|
+
current.capture = undefined;
|
|
154
|
+
current.acquired = false;
|
|
155
|
+
current.ready = false;
|
|
156
|
+
// Cleanup also awaits this teardown if stop races a format change.
|
|
157
|
+
current.media = (async () => {
|
|
158
|
+
try {
|
|
159
|
+
await capture.release();
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
if (acquired) {
|
|
163
|
+
audio.microphone.release();
|
|
164
|
+
await audio.unroute();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
})();
|
|
168
|
+
await current.media;
|
|
169
|
+
if (!active())
|
|
170
|
+
return;
|
|
171
|
+
current.media = undefined;
|
|
172
|
+
}
|
|
134
173
|
if (current.media) {
|
|
135
174
|
current.ready = true;
|
|
136
175
|
stats.providerReconnects++;
|
|
@@ -138,6 +177,8 @@ export function realtimeClient(options) {
|
|
|
138
177
|
flush();
|
|
139
178
|
break;
|
|
140
179
|
}
|
|
180
|
+
current.inputRate = frame.inputRate;
|
|
181
|
+
current.outputRate = frame.outputRate;
|
|
141
182
|
current.media = (async () => {
|
|
142
183
|
await audio.microphone.acquire();
|
|
143
184
|
current.acquired = true;
|
|
@@ -207,6 +248,7 @@ export function realtimeClient(options) {
|
|
|
207
248
|
break;
|
|
208
249
|
case 'realtime.audio.clear':
|
|
209
250
|
audio.player.clear();
|
|
251
|
+
flush();
|
|
210
252
|
break;
|
|
211
253
|
case 'realtime.transcript':
|
|
212
254
|
options.onTurn(frame.role, frame.text);
|
|
@@ -214,11 +256,15 @@ export function realtimeClient(options) {
|
|
|
214
256
|
case 'realtime.state':
|
|
215
257
|
if (frame.state === 'connecting') {
|
|
216
258
|
current.ready = false;
|
|
217
|
-
reset();
|
|
259
|
+
reset(!options.preserveMediaOnReconnect);
|
|
260
|
+
}
|
|
261
|
+
if (frame.state === 'connected') {
|
|
262
|
+
let drained = false;
|
|
263
|
+
const onDrained = () => { if (!active() || drained)
|
|
264
|
+
return; drained = true; options.onStatus('connected', frame.detail); flush(); };
|
|
265
|
+
if (!audio.player.finish(onDrained))
|
|
266
|
+
onDrained();
|
|
218
267
|
}
|
|
219
|
-
if (frame.state === 'connected')
|
|
220
|
-
audio.player.finish(() => { if (active())
|
|
221
|
-
options.onStatus('connected', frame.detail); });
|
|
222
268
|
else
|
|
223
269
|
options.onStatus(frame.state, frame.detail);
|
|
224
270
|
break;
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/node.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { RealtimeClientFrame, RealtimeHostFrame } from './frames.ts';
|
|
2
2
|
import type { RealtimeAuth, RealtimeConfig, RealtimeProviderId, RealtimeUsage } from './types.ts';
|
|
3
|
+
import type { RealtimeSignalingIdentity } from './adapter.ts';
|
|
3
4
|
import { toolBridge } from './tools.ts';
|
|
4
|
-
|
|
5
|
+
import type { RealtimeAuthCheckOptions } from './auth.ts';
|
|
6
|
+
export { toolBridge, appBridge, delegationHandler } from './tools.ts';
|
|
7
|
+
export { realtimeAuthCheck } from './auth.ts';
|
|
8
|
+
export type { RealtimeAuthCheckOptions, RealtimeAuthStatus } from './auth.ts';
|
|
5
9
|
export type { ToolHandler, ToolBridgeOptions } from './tools.ts';
|
|
10
|
+
export type { RealtimeSignalingIdentity } from './adapter.ts';
|
|
6
11
|
export type RealtimeEngineOptions = RealtimeConfig & {
|
|
7
12
|
engine: RealtimeProviderId;
|
|
8
13
|
auth: RealtimeAuth;
|
|
@@ -12,6 +17,14 @@ export type RealtimeEngineOptions = RealtimeConfig & {
|
|
|
12
17
|
endpoint?: string;
|
|
13
18
|
redact?: RegExp[];
|
|
14
19
|
onUsage?(usage: RealtimeUsage): void;
|
|
20
|
+
/** Optional read-only advisory check; never awaited before starting the provider. */
|
|
21
|
+
authCheck?: RealtimeAuthCheckOptions;
|
|
22
|
+
/** App phrase policy; close after the next completed agent transcript. */
|
|
23
|
+
hangup?(text: string): boolean;
|
|
24
|
+
/** Plain app wording for credential acquisition failures, with kit redaction. */
|
|
25
|
+
authFailure?(error: unknown): string;
|
|
26
|
+
/** Explicit app identity for ChatGPT signaling; defaults remain byokit. */
|
|
27
|
+
signalingIdentity?: RealtimeSignalingIdentity;
|
|
15
28
|
};
|
|
16
29
|
/** Provider sockets and SDP signaling run in a kit-owned child, never in the app process. */
|
|
17
30
|
export declare function realtimeEngine(options: RealtimeEngineOptions): {
|
package/dist/node.js
CHANGED
|
@@ -3,8 +3,10 @@ import { createInterface } from 'node:readline';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { parseRealtimeClientFrame, parseRealtimeHostFrame } from "./frames.js";
|
|
5
5
|
import { RealtimeError } from "./types.js";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import { cleanProseBytes } from "./prose.js";
|
|
7
|
+
import { realtimeAuthCheck } from "./auth.js";
|
|
8
|
+
export { toolBridge, appBridge, delegationHandler } from "./tools.js";
|
|
9
|
+
export { realtimeAuthCheck } from "./auth.js";
|
|
8
10
|
/** Provider sockets and SDP signaling run in a kit-owned child, never in the app process. */
|
|
9
11
|
export function realtimeEngine(options) {
|
|
10
12
|
if (!['chatgpt', 'openai', 'gemini', 'xai'].includes(options.engine))
|
|
@@ -13,26 +15,33 @@ export function realtimeEngine(options) {
|
|
|
13
15
|
throw new RealtimeError('unsupported');
|
|
14
16
|
if ((options.engine === 'chatgpt') !== (options.auth.kind === 'plan'))
|
|
15
17
|
throw new RealtimeError('unsupported');
|
|
18
|
+
for (const value of Object.values(options.signalingIdentity ?? {})) {
|
|
19
|
+
if (typeof value !== 'string' || !/^[\x20-\x7E]{1,160}$/.test(value) || !value.trim())
|
|
20
|
+
throw new RealtimeError('protocol');
|
|
21
|
+
}
|
|
16
22
|
let stopped = false, emittedClose = false;
|
|
23
|
+
let ending = false;
|
|
17
24
|
const lifetime = new AbortController();
|
|
25
|
+
const authCheck = options.authCheck ? realtimeAuthCheck(options.authCheck) : undefined;
|
|
18
26
|
const start = Date.now();
|
|
19
27
|
let child;
|
|
28
|
+
let configured = false;
|
|
20
29
|
let killTimer;
|
|
21
30
|
let queuedBytes = 0;
|
|
22
31
|
const pending = [];
|
|
23
32
|
const toolCalls = new Map();
|
|
24
|
-
const secrets = [];
|
|
33
|
+
const secrets = options.auth.kind === 'key' ? [options.auth.key] : [];
|
|
25
34
|
const usage = { seconds: 0, basis: options.engine === 'chatgpt' ? 'subscription' : options.engine === 'xai' ? 'minutes' : 'tokens' };
|
|
26
|
-
const safe = (value) => { let result = value; for (const secret of secrets)
|
|
35
|
+
const safe = (value, max = 2048) => { let result = value; for (const secret of secrets)
|
|
27
36
|
if (secret)
|
|
28
|
-
result = result.replaceAll(secret, '[hidden]'); return
|
|
37
|
+
result = result.replaceAll(secret, '[hidden]'); return cleanProseBytes(result, 'Voice ended.', max, options.redact); };
|
|
29
38
|
const emit = (frame) => {
|
|
30
39
|
if (frame.type === 'realtime.state' && frame.detail)
|
|
31
|
-
frame = { ...frame, detail: safe(frame.detail) };
|
|
40
|
+
frame = { ...frame, detail: safe(frame.detail, 500) };
|
|
32
41
|
if (frame.type === 'realtime.closed' && frame.reason)
|
|
33
42
|
frame = { ...frame, reason: safe(frame.reason) };
|
|
34
43
|
if (frame.type === 'realtime.transcript')
|
|
35
|
-
frame = { ...frame, text: safe(frame.text) };
|
|
44
|
+
frame = { ...frame, text: safe(frame.text, 4000) };
|
|
36
45
|
// Inspect every outbound string, including data-channel payloads, for exact credentials.
|
|
37
46
|
const serialized = JSON.stringify(frame);
|
|
38
47
|
if (secrets.some(secret => secret && serialized.includes(secret))) {
|
|
@@ -52,6 +61,7 @@ export function realtimeEngine(options) {
|
|
|
52
61
|
emittedClose = true;
|
|
53
62
|
stopped = true;
|
|
54
63
|
lifetime.abort();
|
|
64
|
+
authCheck?.close();
|
|
55
65
|
options.bridge.close();
|
|
56
66
|
usage.seconds = (Date.now() - start) / 1000;
|
|
57
67
|
options.onUsage?.({ ...usage });
|
|
@@ -70,15 +80,15 @@ export function realtimeEngine(options) {
|
|
|
70
80
|
return true;
|
|
71
81
|
};
|
|
72
82
|
const ready = (async () => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return;
|
|
76
|
-
secrets.push(credential.access);
|
|
83
|
+
// Warm the isolated child while the app resolves access. No credential is
|
|
84
|
+
// sent until access succeeds; queued device frames still follow its config.
|
|
77
85
|
const entry = new URL(`./child.${import.meta.url.endsWith('.ts') ? 'ts' : 'js'}`, import.meta.url);
|
|
78
86
|
child = spawn(process.execPath, [fileURLToPath(entry)], { env: {}, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
79
87
|
child.stdin.on('error', () => finish('Voice input failed.'));
|
|
80
88
|
child.on('error', () => finish('Voice could not start.'));
|
|
81
|
-
child.on('exit', () =>
|
|
89
|
+
child.on('exit', () => clearTimeout(killTimer));
|
|
90
|
+
// 'close' follows drained stdio, so a final provider reason wins over exit.
|
|
91
|
+
child.on('close', () => finish('Voice provider disconnected.'));
|
|
82
92
|
// Never forward stderr: providers may include credentials in URLs or errors.
|
|
83
93
|
child.stderr.resume();
|
|
84
94
|
let lineBytes = 0;
|
|
@@ -98,6 +108,11 @@ export function realtimeEngine(options) {
|
|
|
98
108
|
return;
|
|
99
109
|
try {
|
|
100
110
|
const raw = JSON.parse(line);
|
|
111
|
+
if (raw.type === 'kit.ready') {
|
|
112
|
+
if (options.engine === 'chatgpt')
|
|
113
|
+
clearTimeout(startupTimer);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
101
116
|
if (raw.type === 'kit.tool.cancel') {
|
|
102
117
|
toolCalls.get(raw.id)?.abort();
|
|
103
118
|
return;
|
|
@@ -108,6 +123,13 @@ export function realtimeEngine(options) {
|
|
|
108
123
|
void options.bridge.run(raw.name, raw.args, raw.id, AbortSignal.any([lifetime.signal, controller.signal])).then(output => { send(`${JSON.stringify({ type: 'kit.tool.result', id: raw.id, output })}\n`); }).catch(() => finish('Voice tool failed.')).finally(() => toolCalls.delete(raw.id));
|
|
109
124
|
return;
|
|
110
125
|
}
|
|
126
|
+
// Normalize prose before byte-bound admission, including provider Unicode.
|
|
127
|
+
if (raw.type === 'realtime.state' && typeof raw.detail === 'string')
|
|
128
|
+
raw.detail = safe(raw.detail, 500);
|
|
129
|
+
if (raw.type === 'realtime.transcript' && typeof raw.text === 'string')
|
|
130
|
+
raw.text = safe(raw.text, 4000);
|
|
131
|
+
if (raw.type === 'realtime.closed' && typeof raw.reason === 'string')
|
|
132
|
+
raw.reason = safe(raw.reason);
|
|
111
133
|
const frame = parseRealtimeHostFrame(raw);
|
|
112
134
|
if (frame.type === 'realtime.ready' || frame.type === 'realtime.webrtc.start')
|
|
113
135
|
clearTimeout(startupTimer);
|
|
@@ -125,13 +147,36 @@ export function realtimeEngine(options) {
|
|
|
125
147
|
}
|
|
126
148
|
if (frame.type === 'realtime.transcript' && frame.role === 'agent')
|
|
127
149
|
options.bridge.answered();
|
|
150
|
+
if (frame.type === 'realtime.state') {
|
|
151
|
+
options.bridge.state(frame.state, frame.detail, emit);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (frame.type === 'realtime.transcript' && frame.role === 'user' && options.hangup?.(frame.text))
|
|
155
|
+
ending = true;
|
|
128
156
|
emit(frame);
|
|
157
|
+
if (frame.type === 'realtime.transcript' && frame.role === 'agent' && ending)
|
|
158
|
+
finish('ended');
|
|
129
159
|
}
|
|
130
160
|
catch {
|
|
131
161
|
finish('Voice provider sent an invalid frame.');
|
|
132
162
|
}
|
|
133
163
|
});
|
|
134
|
-
|
|
164
|
+
// Local media setup overlaps access and child loading instead of waiting for both.
|
|
165
|
+
if (options.engine === 'chatgpt')
|
|
166
|
+
emit({ type: 'realtime.webrtc.start', dataChannelLabel: 'oai-events' });
|
|
167
|
+
let credential;
|
|
168
|
+
try {
|
|
169
|
+
credential = options.auth.kind === 'key' ? { access: options.auth.key, accountId: '' } : await options.auth.access(lifetime.signal);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
finish(options.authFailure?.(error) ?? (error instanceof Error ? error.message : 'Voice sign-in is unavailable.'));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (stopped)
|
|
176
|
+
return;
|
|
177
|
+
secrets.push(credential.access);
|
|
178
|
+
send(`${JSON.stringify({ engine: options.engine, key: credential.access, accountId: credential.accountId, instructions: options.instructions ?? '', tools: options.tools ?? [], model: options.model, voice: options.voice, endpoint: options.endpoint, signalingIdentity: options.signalingIdentity, redact: options.redact?.map(pattern => ({ source: pattern.source, flags: pattern.flags })) })}\n`);
|
|
179
|
+
configured = true;
|
|
135
180
|
for (const line of pending)
|
|
136
181
|
if (!send(line)) {
|
|
137
182
|
finish('Voice input overflowed.');
|
|
@@ -145,8 +190,11 @@ export function realtimeEngine(options) {
|
|
|
145
190
|
receive(raw) {
|
|
146
191
|
if (stopped)
|
|
147
192
|
return false;
|
|
148
|
-
const frame = parseRealtimeClientFrame(raw)
|
|
149
|
-
if (
|
|
193
|
+
const frame = parseRealtimeClientFrame(raw);
|
|
194
|
+
if (options.bridge.receive(frame))
|
|
195
|
+
return true;
|
|
196
|
+
const line = `${JSON.stringify(frame)}\n`;
|
|
197
|
+
if (!configured) {
|
|
150
198
|
const bytes = Buffer.byteLength(line);
|
|
151
199
|
if (queuedBytes + bytes > 128 * 1024) {
|
|
152
200
|
finish('Voice input overflowed.');
|
package/dist/prose.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export declare const cleanProse: (value: unknown, fallback: string, max: number, redact?: RegExp[]) => string;
|
|
2
|
+
/** Frame limits are UTF-8 bytes; truncate on whole Unicode characters. */
|
|
3
|
+
export declare function cleanProseBytes(value: unknown, fallback: string, max: number, redact?: RegExp[]): string;
|
|
2
4
|
/**
|
|
3
5
|
* The provider explains a refusal in the HTTP body; the close code does not.
|
|
4
6
|
* An out-of-credits 403 is otherwise indistinguishable from a dropped network,
|
package/dist/prose.js
CHANGED
|
@@ -14,6 +14,20 @@ export const cleanProse = (value, fallback, max, redact = []) => {
|
|
|
14
14
|
.replace(/\s+/g, ' ').trim().slice(0, max);
|
|
15
15
|
return clean || fallback;
|
|
16
16
|
};
|
|
17
|
+
/** Frame limits are UTF-8 bytes; truncate on whole Unicode characters. */
|
|
18
|
+
export function cleanProseBytes(value, fallback, max, redact = []) {
|
|
19
|
+
const clean = cleanProse(value, fallback, max, redact);
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
let result = '', bytes = 0;
|
|
22
|
+
for (const char of clean) {
|
|
23
|
+
const size = encoder.encode(char).length;
|
|
24
|
+
if (bytes + size > max)
|
|
25
|
+
break;
|
|
26
|
+
result += char;
|
|
27
|
+
bytes += size;
|
|
28
|
+
}
|
|
29
|
+
return result.trim();
|
|
30
|
+
}
|
|
17
31
|
/**
|
|
18
32
|
* The provider explains a refusal in the HTTP body; the close code does not.
|
|
19
33
|
* An out-of-credits 403 is otherwise indistinguishable from a dropped network,
|
package/dist/tools.d.ts
CHANGED
|
@@ -11,15 +11,22 @@ export type ToolBridgeOptions = {
|
|
|
11
11
|
timeoutFor?: (name: string) => number;
|
|
12
12
|
answerTimeoutMs?: number;
|
|
13
13
|
failure(name: string, error: unknown, timedOut: boolean): string;
|
|
14
|
+
/** Route semantic app replies to the same bridge used by tool handlers. */
|
|
15
|
+
app?: ReturnType<typeof appBridge>;
|
|
14
16
|
};
|
|
15
17
|
/** Bounded, deduplicated tool execution. Product authorization stays in handlers. */
|
|
16
18
|
export declare function toolBridge(options: ToolBridgeOptions): {
|
|
17
19
|
run: (name: string, args?: unknown, id?: string, signal?: AbortSignal) => Promise<string>;
|
|
18
|
-
state: (value: RealtimeState, detail?: string) => void;
|
|
20
|
+
state: (value: RealtimeState, detail?: string, emit?: (frame: RealtimeHostFrame) => void) => void;
|
|
19
21
|
answered: () => void;
|
|
20
|
-
receive: (
|
|
22
|
+
receive: (frame: RealtimeClientFrame) => boolean;
|
|
21
23
|
close(): void;
|
|
22
24
|
};
|
|
25
|
+
/** Structured requests use the app's allowlisted tools; prose uses its planner. */
|
|
26
|
+
export declare function delegationHandler(options: {
|
|
27
|
+
bridge: ReturnType<typeof toolBridge>;
|
|
28
|
+
plan?: (request: string, context: Parameters<ToolHandler>[1]) => Promise<string>;
|
|
29
|
+
}): ToolHandler;
|
|
23
30
|
export declare function appBridge(emit: (frame: RealtimeHostFrame) => void | boolean, options?: {
|
|
24
31
|
timeoutMs?: number;
|
|
25
32
|
maxPending?: number;
|
package/dist/tools.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { cleanProseBytes } from "./prose.js";
|
|
2
3
|
/** Bounded, deduplicated tool execution. Product authorization stays in handlers. */
|
|
3
4
|
export function toolBridge(options) {
|
|
4
5
|
const lifetime = new AbortController();
|
|
5
6
|
const requests = new Map();
|
|
6
7
|
let active = 0, waiting = false;
|
|
7
8
|
let answerTimer;
|
|
8
|
-
const state = (value, detail
|
|
9
|
+
const state = (value, detail, emit = options.emit) => emit({ type: 'realtime.state', state: value === 'connected' && (active > 0 || waiting) ? 'thinking' : value, ...(detail ? { detail: cleanProseBytes(detail, 'Voice could not complete that request.', 500) } : {}) });
|
|
9
10
|
const answered = () => { if (active)
|
|
10
11
|
return; clearTimeout(answerTimer); waiting = false; };
|
|
11
12
|
const run = (name, args = {}, id = randomUUID(), signal) => {
|
|
@@ -57,7 +58,34 @@ export function toolBridge(options) {
|
|
|
57
58
|
requests.set(id, { key, promise });
|
|
58
59
|
return promise;
|
|
59
60
|
};
|
|
60
|
-
return { run, state, answered, receive: (
|
|
61
|
+
return { run, state, answered, receive: (frame) => options.app?.receive(frame) ?? false, close() { lifetime.abort(); options.app?.close(); clearTimeout(answerTimer); waiting = false; } };
|
|
62
|
+
}
|
|
63
|
+
/** Structured requests use the app's allowlisted tools; prose uses its planner. */
|
|
64
|
+
export function delegationHandler(options) {
|
|
65
|
+
return async (args, context) => {
|
|
66
|
+
context.signal.throwIfAborted();
|
|
67
|
+
const request = args.request;
|
|
68
|
+
if (typeof request !== 'string' || !request.trim() || Buffer.byteLength(request) > 16000)
|
|
69
|
+
return 'That delegated request is invalid.';
|
|
70
|
+
let structured;
|
|
71
|
+
try {
|
|
72
|
+
structured = JSON.parse(request);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// JSON-looking input is never reinterpreted as permission to plan an action.
|
|
76
|
+
if (/^[\s]*[\[{]/.test(request))
|
|
77
|
+
return 'That delegated request is invalid.';
|
|
78
|
+
return options.plan ? options.plan(request, context) : 'That delegation needs an app planner.';
|
|
79
|
+
}
|
|
80
|
+
if (!structured || typeof structured !== 'object' || Array.isArray(structured))
|
|
81
|
+
return 'That delegated request is invalid.';
|
|
82
|
+
const call = structured;
|
|
83
|
+
if (typeof call.name !== 'string' || !call.name.trim() || call.name === 'delegate'
|
|
84
|
+
|| Object.keys(call).some(key => key !== 'name' && key !== 'arguments'))
|
|
85
|
+
return 'That delegated request is invalid.';
|
|
86
|
+
// Arguments, including named targets, stay intact. The app validates and authorizes them.
|
|
87
|
+
return options.bridge.run(call.name, call.arguments === undefined ? {} : call.arguments, context.id, context.signal);
|
|
88
|
+
};
|
|
61
89
|
}
|
|
62
90
|
export function appBridge(emit, options = {}) {
|
|
63
91
|
const pending = new Map();
|
package/dist/types.d.ts
CHANGED
|
@@ -63,6 +63,7 @@ export type AudioPorts = {
|
|
|
63
63
|
unbind(sink: PlaybackSink): void;
|
|
64
64
|
admit(base64: string): 'ok' | 'malformed' | 'overflow';
|
|
65
65
|
clear(): void;
|
|
66
|
+
/** True means a drain callback was scheduled; false means nothing needs draining. */
|
|
66
67
|
finish(onDrained?: () => void): boolean;
|
|
67
68
|
afterDrain(kind: 'connected' | 'speech', run: () => void): boolean;
|
|
68
69
|
stop(): void;
|
|
@@ -85,6 +86,9 @@ export type RealtimeClientOptions = {
|
|
|
85
86
|
audio: AudioPorts;
|
|
86
87
|
/** Transport closures retry by default. Return false for intentional or terminal closures. */
|
|
87
88
|
retryableClose?(reason?: string): boolean;
|
|
89
|
+
/** Retain ready PCM capture and the playback tail across carrier reconnects.
|
|
90
|
+
* WebRTC still needs a fresh peer. Pending microphone acquisition is never retained. */
|
|
91
|
+
preserveMediaOnReconnect?: boolean;
|
|
88
92
|
webrtc?: (options: import('./webrtc.ts').WebRtcOptions) => Promise<import('./webrtc.ts').WebRtcHandle>;
|
|
89
93
|
onStatus(status: 'connecting' | 'connected' | 'thinking' | 'speaking' | 'disconnected', reason?: string): void;
|
|
90
94
|
onTurn(role: 'user' | 'agent', text: string): void;
|
package/dist/webrtc.js
CHANGED
|
@@ -36,7 +36,8 @@ export async function webRtcPeer(options) {
|
|
|
36
36
|
} };
|
|
37
37
|
try {
|
|
38
38
|
options.onConnectionState('connecting');
|
|
39
|
-
options.signal?.
|
|
39
|
+
if (options.signal?.aborted)
|
|
40
|
+
throw new Error('Voice is closed.');
|
|
40
41
|
await options.audio.microphone.acquire();
|
|
41
42
|
acquired = true;
|
|
42
43
|
if (stopped) {
|