@nexus-code/remote-protocol 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/src/rpc.ts ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * What one device asks another, sealed: JSON-RPC 2.0 requests, their
3
+ * answers, and the notifications a watch sends. The device that runs the
4
+ * sessions serves; the one that shows them asks.
5
+ *
6
+ * A session is named by the serving device's own ids: its workspace's and
7
+ * its tab's. The same folder has other ids on another device.
8
+ */
9
+ import { z } from "zod";
10
+ import {
11
+ AssistantItemSchema,
12
+ ChatStatusSchema,
13
+ ViewItemSchema,
14
+ ViewTaskSchema,
15
+ ViewToolCallSchema,
16
+ } from "./view.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // JSON-RPC 2.0 shapes
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export const RpcIdSchema = z.union([z.number().int(), z.string()]);
23
+
24
+ export const RpcRequestSchema = z.object({
25
+ jsonrpc: z.literal("2.0"),
26
+ id: RpcIdSchema,
27
+ method: z.string(),
28
+ params: z.unknown().optional(),
29
+ });
30
+ export type RpcRequest = z.infer<typeof RpcRequestSchema>;
31
+
32
+ export const RpcErrorSchema = z.object({
33
+ code: z.number().int(),
34
+ message: z.string(),
35
+ data: z.unknown().optional(),
36
+ });
37
+ export type RpcError = z.infer<typeof RpcErrorSchema>;
38
+
39
+ // The error first: `result: z.unknown()` takes an object without one too,
40
+ // and would strip an error answer's `error`.
41
+ export const RpcResponseSchema = z.union([
42
+ z.object({ jsonrpc: z.literal("2.0"), id: RpcIdSchema, error: RpcErrorSchema }),
43
+ z.object({ jsonrpc: z.literal("2.0"), id: RpcIdSchema, result: z.unknown() }),
44
+ ]);
45
+ export type RpcResponse = z.infer<typeof RpcResponseSchema>;
46
+
47
+ export const RpcNotificationSchema = z.object({
48
+ jsonrpc: z.literal("2.0"),
49
+ method: z.string(),
50
+ params: z.unknown().optional(),
51
+ });
52
+ export type RpcNotification = z.infer<typeof RpcNotificationSchema>;
53
+
54
+ export const RpcMessageSchema = z.union([
55
+ RpcRequestSchema,
56
+ RpcResponseSchema,
57
+ RpcNotificationSchema,
58
+ ]);
59
+ export type RpcMessage = z.infer<typeof RpcMessageSchema>;
60
+
61
+ /** The error codes: JSON-RPC's own, and the protocol's from 1000. */
62
+ export const RpcErrorCode = {
63
+ ParseError: -32700,
64
+ InvalidRequest: -32600,
65
+ MethodNotFound: -32601,
66
+ InvalidParams: -32602,
67
+ InternalError: -32603,
68
+ /** No such workspace, tab or watch on the serving device. */
69
+ NotFound: 1001,
70
+ /** The serving device does not let this device do it. */
71
+ Forbidden: 1002,
72
+ /** The session cannot do it now: a chat with no request waiting, one whose program is gone. */
73
+ Unavailable: 1003,
74
+ } as const;
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Sessions
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /** A workspace of the serving device. */
81
+ export const RemoteWorkspaceSchema = z.object({
82
+ id: z.string(),
83
+ name: z.string(),
84
+ /** Its folder, on the serving device. */
85
+ path: z.string(),
86
+ });
87
+ export type RemoteWorkspace = z.infer<typeof RemoteWorkspaceSchema>;
88
+
89
+ /** Where a session stands, as the agent lists show it. */
90
+ export const SessionStateSchema = z.enum(["needs-you", "running", "new-result", "idle", "ended"]);
91
+ export type SessionState = z.infer<typeof SessionStateSchema>;
92
+
93
+ /** An agent session or terminal of the serving device. */
94
+ export const RemoteSessionSchema = z.object({
95
+ workspaceId: z.string(),
96
+ tabId: z.string(),
97
+ kind: z.enum(["claude", "codex", "terminal"]),
98
+ title: z.string(),
99
+ state: SessionStateSchema,
100
+ /** When it came to that state, in Unix milliseconds. */
101
+ since: z.number().optional(),
102
+ /** The last thing it said, a line. */
103
+ last: z.string().optional(),
104
+ /** The agent profile it runs with. */
105
+ profile: z.string().optional(),
106
+ /** What it waits on the user for, when it does. */
107
+ waiting: z
108
+ .object({
109
+ kind: z.enum(["permission", "question", "plan"]),
110
+ text: z.string(),
111
+ })
112
+ .optional(),
113
+ });
114
+ export type RemoteSession = z.infer<typeof RemoteSessionSchema>;
115
+
116
+ const SessionsSchema = z.object({
117
+ workspaces: z.array(RemoteWorkspaceSchema),
118
+ sessions: z.array(RemoteSessionSchema),
119
+ });
120
+
121
+ /** A session of the serving device, by its ids there. */
122
+ const TabRef = { workspaceId: z.string(), tabId: z.string() };
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Chats
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /** Most items one chat.read gives; earlier ones come by `before`. */
129
+ export const MAX_READ_ITEMS = 500;
130
+
131
+ /**
132
+ * A page of a chat: its status, the items from `start` (before `total`),
133
+ * and the tool calls, tasks and subagents' messages they name.
134
+ */
135
+ export const ChatPageSchema = z.object({
136
+ status: ChatStatusSchema,
137
+ start: z.number().int().nonnegative(),
138
+ total: z.number().int().nonnegative(),
139
+ items: z.array(ViewItemSchema),
140
+ tools: z.record(ViewToolCallSchema),
141
+ tasks: z.record(ViewTaskSchema),
142
+ agentItems: z.record(z.array(AssistantItemSchema)),
143
+ });
144
+ export type ChatPage = z.infer<typeof ChatPageSchema>;
145
+
146
+ /**
147
+ * What changed in a watched chat since its last update (or its watch
148
+ * began). Items are put at their index; `total` cuts the list when it got
149
+ * shorter. Tool calls, tasks and subagents' messages are put by id.
150
+ */
151
+ export const ChatUpdateSchema = z.object({
152
+ watchId: z.string(),
153
+ /** Counts the watch's updates from 1, so one lost shows. */
154
+ rev: z.number().int().positive(),
155
+ status: ChatStatusSchema.optional(),
156
+ total: z.number().int().nonnegative(),
157
+ items: z.array(z.object({ index: z.number().int().nonnegative(), item: ViewItemSchema })),
158
+ tools: z.record(ViewToolCallSchema),
159
+ tasks: z.record(ViewTaskSchema),
160
+ agentItems: z.record(z.array(AssistantItemSchema)),
161
+ });
162
+ export type ChatUpdate = z.infer<typeof ChatUpdateSchema>;
163
+
164
+ /** The sessions' list as it is now, sent whenever it changes during a sessions.watch. */
165
+ export const SessionsUpdateSchema = SessionsSchema.extend({ watchId: z.string() });
166
+ export type SessionsUpdate = z.infer<typeof SessionsUpdateSchema>;
167
+
168
+ const Ok = z.object({});
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // The methods
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Each method's params and result, and the capability (see ./sealed.ts)
176
+ * the serving device lists for it.
177
+ */
178
+ export const RPC_METHODS = {
179
+ "sessions.list": { capability: "sessions", params: Ok, result: SessionsSchema },
180
+ /** The list now; then a sessions.update each time it changes, until sessions.unwatch. */
181
+ "sessions.watch": {
182
+ capability: "sessions",
183
+ params: Ok,
184
+ result: SessionsSchema.extend({ watchId: z.string() }),
185
+ },
186
+ "sessions.unwatch": {
187
+ capability: "sessions",
188
+ params: z.object({ watchId: z.string() }),
189
+ result: Ok,
190
+ },
191
+
192
+ /** The chat's last `limit` items, or those before `before`. */
193
+ "chat.read": {
194
+ capability: "chat.read",
195
+ params: z.object({
196
+ ...TabRef,
197
+ before: z.number().int().nonnegative().optional(),
198
+ limit: z.number().int().positive().max(MAX_READ_ITEMS).optional(),
199
+ }),
200
+ result: ChatPageSchema,
201
+ },
202
+ /** The chat's last page now; then a chat.update each time it changes, until chat.unwatch. */
203
+ "chat.watch": {
204
+ capability: "chat.read",
205
+ params: z.object({
206
+ ...TabRef,
207
+ limit: z.number().int().positive().max(MAX_READ_ITEMS).optional(),
208
+ }),
209
+ result: ChatPageSchema.extend({ watchId: z.string() }),
210
+ },
211
+ "chat.unwatch": {
212
+ capability: "chat.read",
213
+ params: z.object({ watchId: z.string() }),
214
+ result: Ok,
215
+ },
216
+
217
+ /** Sends a message, as its composer does: during a turn, the turn reads it next. */
218
+ "chat.send": {
219
+ capability: "chat.control",
220
+ params: z.object({ ...TabRef, text: z.string().min(1) }),
221
+ result: Ok,
222
+ },
223
+ /** Answers the permission request `requestId`, as its card's buttons do. */
224
+ "chat.answer": {
225
+ capability: "chat.control",
226
+ params: z.object({
227
+ ...TabRef,
228
+ requestId: z.string(),
229
+ decision: z.enum(["allow", "deny"]),
230
+ /** Why, for a denial: the program reads it. */
231
+ message: z.string().optional(),
232
+ /** With allow: the input the tool runs with, as the user filled it (a question's answers). */
233
+ updatedInput: z.record(z.unknown()).optional(),
234
+ /** With deny: the turn stops too, as the card's "No, and stop" does. */
235
+ stop: z.boolean().optional(),
236
+ }),
237
+ result: Ok,
238
+ },
239
+ "chat.interrupt": { capability: "chat.control", params: z.object(TabRef), result: Ok },
240
+ /** Opens a chat in one of the serving device's workspaces, sending it `prompt` if given. */
241
+ "chat.new": {
242
+ capability: "chat.control",
243
+ params: z.object({
244
+ workspaceId: z.string(),
245
+ agent: z.enum(["claude", "codex"]),
246
+ prompt: z.string().optional(),
247
+ }),
248
+ result: z.object({ tabId: z.string() }),
249
+ },
250
+ "chat.close": { capability: "chat.control", params: z.object(TabRef), result: Ok },
251
+
252
+ /** A terminal's screen and the lines above it, as text. Nothing can be typed into it. */
253
+ "terminal.read": {
254
+ capability: "terminal.read",
255
+ params: z.object({ ...TabRef, lines: z.number().int().positive().max(5000).optional() }),
256
+ result: z.object({ text: z.string() }),
257
+ },
258
+ } as const;
259
+
260
+ export type RpcMethod = keyof typeof RPC_METHODS;
261
+ export type RpcParams<M extends RpcMethod> = z.infer<(typeof RPC_METHODS)[M]["params"]>;
262
+ export type RpcResult<M extends RpcMethod> = z.infer<(typeof RPC_METHODS)[M]["result"]>;
263
+
264
+ /** The notifications a serving device sends, by method. */
265
+ export const RPC_NOTIFICATIONS = {
266
+ "sessions.update": SessionsUpdateSchema,
267
+ "chat.update": ChatUpdateSchema,
268
+ } as const;
269
+
270
+ export type RpcNotificationMethod = keyof typeof RPC_NOTIFICATIONS;
271
+ export type RpcNotificationParams<M extends RpcNotificationMethod> = z.infer<
272
+ (typeof RPC_NOTIFICATIONS)[M]
273
+ >;
274
+
275
+ export function isRpcMethod(method: string): method is RpcMethod {
276
+ return Object.hasOwn(RPC_METHODS, method);
277
+ }
package/src/sealed.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * What a sealed message carries, which only the account's devices can open.
3
+ *
4
+ * Sealing (v1): XChaCha20-Poly1305 with the room key, a fresh random 24-byte
5
+ * nonce per message, and the frame's routing as associated data (aadOf), so
6
+ * the relay can neither read a message nor pass it off as another's. The
7
+ * room key comes from the account key (see the account key design); every
8
+ * device of the account holds it. The bytes sealed are the message's JSON
9
+ * in UTF-8.
10
+ */
11
+ import { z } from "zod";
12
+ import { type DeviceId, DeviceIdSchema } from "./relay.js";
13
+ import { RpcMessageSchema } from "./rpc.js";
14
+
15
+ /** The version of what is sealed; a device tells it in its hello. */
16
+ export const PROTOCOL_VERSION = 1;
17
+
18
+ /** The HKDF info that makes the room key from the account key. */
19
+ export const ROOM_KEY_INFO = "nexus-remote/room-key/v1";
20
+
21
+ /**
22
+ * The associated data a message is sealed with: its routing, as the relay
23
+ * delivers it. A message the relay sends on to another device, or with
24
+ * another seq, does not open.
25
+ */
26
+ export function aadOf(frame: { from: DeviceId; to: string; seq: number; kid: number }): string {
27
+ return `nexus-remote/v${PROTOCOL_VERSION}|${frame.from}|${frame.to}|${frame.seq}|${frame.kid}`;
28
+ }
29
+
30
+ /**
31
+ * What a device can do for another, as it says in its hello. The other
32
+ * side asks only for what is listed; a newer device lists more.
33
+ */
34
+ export const CAPABILITIES = [
35
+ /** sessions.list and the session events. */
36
+ "sessions",
37
+ /** chat.read and chat.watch. */
38
+ "chat.read",
39
+ /** chat.send, chat.answer, chat.interrupt, chat.new and chat.close. */
40
+ "chat.control",
41
+ /** terminal.read: a terminal's screen, as text. */
42
+ "terminal.read",
43
+ ] as const;
44
+ export const CapabilitySchema = z.string();
45
+ export type Capability = (typeof CAPABILITIES)[number];
46
+
47
+ /**
48
+ * A device introduces itself: to all the room as it joins, and to one that
49
+ * just joined. What it serves of its own sessions is `serves`; a device
50
+ * that only watches others (a phone) serves nothing.
51
+ */
52
+ export const HelloSchema = z.object({
53
+ type: z.literal("hello"),
54
+ protocol: z.number().int().positive(),
55
+ device: z.object({
56
+ id: DeviceIdSchema,
57
+ name: z.string(),
58
+ platform: z.string(),
59
+ }),
60
+ app: z.object({ name: z.string(), version: z.string() }),
61
+ serves: z.array(CapabilitySchema),
62
+ /** Whether it wants the hello of devices already there: true as it joins. */
63
+ reply: z.boolean(),
64
+ });
65
+ export type Hello = z.infer<typeof HelloSchema>;
66
+
67
+ /** A sealed message's content. */
68
+ export const SealedMessageSchema = z.union([HelloSchema, RpcMessageSchema]);
69
+ export type SealedMessage = z.infer<typeof SealedMessageSchema>;
package/src/view.ts ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * A chat as another device draws it: the conversation the serving device's
3
+ * app already made of its program's log, trimmed of what only that app
4
+ * uses. The shapes follow the desktop app's chat model, so its chat view can
5
+ * draw a remote one as it draws its own; Claude Code's and Codex's own
6
+ * formats stay on the serving device, which keeps up with their versions.
7
+ *
8
+ * An item or call of a kind this version does not know stays parseable
9
+ * (UnknownItemSchema): a newer device may send more, and an older one skips it.
10
+ */
11
+ import { z } from "zod";
12
+
13
+ /** An image a message or a tool result holds. Its bytes stay on the serving device in v1. */
14
+ export const ImageRefSchema = z.object({
15
+ mediaType: z.string(),
16
+ /** How many bytes the image takes. */
17
+ bytes: z.number().int().nonnegative(),
18
+ });
19
+ export type ImageRef = z.infer<typeof ImageRefSchema>;
20
+
21
+ const at = z.number().optional();
22
+
23
+ export const UserItemSchema = z.object({
24
+ kind: z.literal("user"),
25
+ key: z.string(),
26
+ text: z.string(),
27
+ images: z.array(ImageRefSchema),
28
+ /** sent: written to the program; taken: the program read it. */
29
+ state: z.enum(["sent", "taken"]),
30
+ /** Written during a turn and not read yet: it waits above the composer. */
31
+ queued: z.boolean().optional(),
32
+ at,
33
+ });
34
+
35
+ export const AssistantBlockSchema = z.discriminatedUnion("type", [
36
+ z.object({ type: z.literal("text"), text: z.string() }),
37
+ z.object({ type: z.literal("thinking"), text: z.string() }),
38
+ z.object({ type: z.literal("tool"), toolUseId: z.string() }),
39
+ ]);
40
+
41
+ export const AssistantItemSchema = z.object({
42
+ kind: z.literal("assistant"),
43
+ key: z.string(),
44
+ messageId: z.string(),
45
+ blocks: z.array(AssistantBlockSchema),
46
+ at,
47
+ });
48
+ export type ViewAssistantItem = z.infer<typeof AssistantItemSchema>;
49
+
50
+ export const NoticeItemSchema = z.object({
51
+ kind: z.literal("notice"),
52
+ key: z.string(),
53
+ /** interrupted, exited, restarted, compacted, cleared, error…: a code the app names. */
54
+ code: z.string(),
55
+ detail: z.string().optional(),
56
+ at,
57
+ });
58
+
59
+ /** A message the program took that the user did not write: another session's, a channel's. */
60
+ export const ReceivedItemSchema = z.object({
61
+ kind: z.literal("received"),
62
+ key: z.string(),
63
+ from: z.object({
64
+ kind: z.string(),
65
+ name: z.string().optional(),
66
+ agent: z.string().optional(),
67
+ }),
68
+ text: z.string(),
69
+ at,
70
+ });
71
+
72
+ export const TaskEndItemSchema = z.object({
73
+ kind: z.literal("task_end"),
74
+ key: z.string(),
75
+ taskId: z.string(),
76
+ status: z.string(),
77
+ description: z.string().optional(),
78
+ taskType: z.string().optional(),
79
+ summary: z.string().optional(),
80
+ toolUseId: z.string().optional(),
81
+ wakes: z.boolean().optional(),
82
+ at,
83
+ });
84
+
85
+ export const ShellItemSchema = z.object({
86
+ kind: z.literal("shell"),
87
+ key: z.string(),
88
+ command: z.string(),
89
+ stdout: z.string().optional(),
90
+ stderr: z.string().optional(),
91
+ at,
92
+ });
93
+
94
+ /** The item kinds this version knows. */
95
+ export const ITEM_KINDS: ReadonlySet<string> = new Set([
96
+ "user",
97
+ "assistant",
98
+ "notice",
99
+ "received",
100
+ "task_end",
101
+ "shell",
102
+ ]);
103
+
104
+ /**
105
+ * An item of a kind this version does not know: kept so the list stays
106
+ * whole, and not drawn. A known kind that does not fit its shape is no such item.
107
+ */
108
+ export const UnknownItemSchema = z
109
+ .object({ kind: z.string().refine((kind) => !ITEM_KINDS.has(kind)), key: z.string() })
110
+ .passthrough();
111
+
112
+ export const ViewItemSchema = z.union([
113
+ UserItemSchema,
114
+ AssistantItemSchema,
115
+ NoticeItemSchema,
116
+ ReceivedItemSchema,
117
+ TaskEndItemSchema,
118
+ ShellItemSchema,
119
+ UnknownItemSchema,
120
+ ]);
121
+ export type ViewItem = z.infer<typeof ViewItemSchema>;
122
+
123
+ /** Most characters of a tool's result text sent; the rest is cut (`truncated`). */
124
+ export const MAX_RESULT_TEXT = 64 * 1024;
125
+
126
+ export const ViewToolCallSchema = z.object({
127
+ id: z.string(),
128
+ name: z.string(),
129
+ displayName: z.string().optional(),
130
+ input: z.unknown(),
131
+ /** streaming, running, done, error, denied, stopped. */
132
+ status: z.string(),
133
+ result: z
134
+ .object({
135
+ text: z.string(),
136
+ isError: z.boolean(),
137
+ /** The program's structured result: a run's stdout, an edit's patch. */
138
+ structured: z.unknown().optional(),
139
+ images: z.array(ImageRefSchema),
140
+ /** The text was longer than MAX_RESULT_TEXT and is cut there. */
141
+ truncated: z.boolean().optional(),
142
+ })
143
+ .optional(),
144
+ /** The agent call it runs under; null on the main thread. */
145
+ parentToolUseId: z.string().nullable(),
146
+ });
147
+ export type ViewToolCall = z.infer<typeof ViewToolCallSchema>;
148
+
149
+ /** A tool the program asks to run, which the user allows or denies. */
150
+ export const ViewPermissionSchema = z.object({
151
+ requestId: z.string(),
152
+ toolName: z.string(),
153
+ displayName: z.string().optional(),
154
+ input: z.record(z.unknown()),
155
+ description: z.string().optional(),
156
+ decisionReason: z.string().optional(),
157
+ toolUseId: z.string().optional(),
158
+ /** A subagent's request. */
159
+ agentId: z.string().optional(),
160
+ /** The call as the card previews it, when the input alone does not tell it. */
161
+ preview: ViewToolCallSchema.optional(),
162
+ });
163
+ export type ViewPermission = z.infer<typeof ViewPermissionSchema>;
164
+
165
+ /** A task the program runs beside the turn: a background run, a subagent. */
166
+ export const ViewTaskSchema = z.object({
167
+ taskId: z.string(),
168
+ type: z.string(),
169
+ description: z.string(),
170
+ status: z.string(),
171
+ toolUseId: z.string().optional(),
172
+ startedAt: z.number().optional(),
173
+ endedAt: z.number().optional(),
174
+ summary: z.string().optional(),
175
+ activity: z.string().optional(),
176
+ });
177
+ export type ViewTask = z.infer<typeof ViewTaskSchema>;
178
+
179
+ /** The reply being written: what the program streamed of its next message so far. */
180
+ export const ViewStreamingSchema = z.object({
181
+ messageId: z.string(),
182
+ blocks: z.array(
183
+ z.object({
184
+ type: z.enum(["text", "thinking", "tool_use"]),
185
+ text: z.string(),
186
+ toolUseId: z.string().optional(),
187
+ }),
188
+ ),
189
+ });
190
+ export type ViewStreaming = z.infer<typeof ViewStreamingSchema>;
191
+
192
+ /** Where a chat stands, apart from its messages. */
193
+ export const ChatStatusSchema = z.object({
194
+ agent: z.enum(["claude", "codex"]),
195
+ title: z.string(),
196
+ /** The model it runs, as its program names it. */
197
+ model: z.string().optional(),
198
+ effort: z.string().optional(),
199
+ permissionMode: z.string().optional(),
200
+ /** A turn is under way. */
201
+ running: z.boolean(),
202
+ process: z.enum(["none", "running", "exited"]),
203
+ turn: z
204
+ .object({
205
+ startedAt: z.number().optional(),
206
+ outputTokens: z.number(),
207
+ /** requesting, compacting… */
208
+ status: z.string().optional(),
209
+ })
210
+ .nullable(),
211
+ streaming: ViewStreamingSchema.nullable(),
212
+ permissions: z.array(ViewPermissionSchema),
213
+ });
214
+ export type ChatStatus = z.infer<typeof ChatStatusSchema>;
package/src/voice.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The voice socket: what a device says goes to the server as audio and
3
+ * comes back as text while it speaks (streaming speech to text).
4
+ *
5
+ * GET /voice?v=1&lang=<language> upgrades to a WebSocket, signed in as
6
+ * /relay is (a Bearer header, or ?ticket= from POST /relay/ticket). Then:
7
+ * - server: `ready`, naming the audio format and sample rate to send, and
8
+ * the most seconds this session may take;
9
+ * - client: binary frames of audio in that format, about 100 ms each, then
10
+ * `end` when the user stops;
11
+ * - server, as they come: `partial` (the words so far of the segment being
12
+ * spoken; each replaces the last) and `final` (a finished segment, to
13
+ * append; it clears the partial). Every partial and final after the
14
+ * session's first final starts with the space its language puts between
15
+ * segments (none for ja or zh): the text is the finals joined as they
16
+ * came, then the partial, with nothing trimmed or added between;
17
+ * - server: `limit` if the audio ran past what the session or the
18
+ * account's quota allows (audio past it is dropped; finals still come);
19
+ * - server: `done` with the seconds counted, once every final is out; then
20
+ * it closes (1000). An `error` ends the session; it closes after.
21
+ */
22
+ import { z } from "zod";
23
+
24
+ export const VOICE_VERSION = 1;
25
+
26
+ /** Most bytes in one audio frame (2 s of 16 kHz 16-bit mono); an even count. */
27
+ export const MAX_AUDIO_FRAME_BYTES = 64 * 1024;
28
+
29
+ /** The language to hear, BCP 47-ish: "ko", "en", "en-US", or "multi" to detect it. */
30
+ export const VoiceLanguageSchema = z.string().regex(/^([a-z]{2,3}(-[A-Z]{2})?|multi)$/);
31
+
32
+ /** What a device sends besides audio: that the user stopped speaking. */
33
+ export const VoiceClientMessageSchema = z.object({ t: z.literal("end") });
34
+ export type VoiceClientMessage = z.infer<typeof VoiceClientMessageSchema>;
35
+
36
+ export const VoiceReadySchema = z.object({
37
+ t: z.literal("ready"),
38
+ format: z.literal("pcm_s16le"),
39
+ /** The rate to send at; it may change with the provider, so it is not to be assumed. */
40
+ sampleRate: z.number().int().positive(),
41
+ /** The fewer of the session's limit and what is left of the month's quota. */
42
+ maxSeconds: z.number().nonnegative(),
43
+ });
44
+
45
+ export const VoiceServerMessageSchema = z.discriminatedUnion("t", [
46
+ VoiceReadySchema,
47
+ z.object({ t: z.literal("partial"), text: z.string() }),
48
+ z.object({ t: z.literal("final"), text: z.string() }),
49
+ z.object({ t: z.literal("limit"), reason: z.enum(["session", "quota"]) }),
50
+ z.object({ t: z.literal("done"), seconds: z.number().nonnegative() }),
51
+ z.object({
52
+ t: z.literal("error"),
53
+ /** unauthorized: the device's sign-in ended (the socket closes 4001 after it). */
54
+ code: z.enum(["bad-frame", "provider-error", "unauthorized"]),
55
+ message: z.string(),
56
+ }),
57
+ ]);
58
+ export type VoiceServerMessage = z.infer<typeof VoiceServerMessageSchema>;
59
+
60
+ /** GET /voice/usage: this month's seconds, used and allowed. */
61
+ export const VoiceUsageSchema = z.object({
62
+ /** The month, as YYYY-MM. */
63
+ period: z.string(),
64
+ usedSeconds: z.number().nonnegative(),
65
+ limitSeconds: z.number().nonnegative(),
66
+ });
67
+ export type VoiceUsage = z.infer<typeof VoiceUsageSchema>;