@frockbot/plugin-voice 0.0.0 → 0.3.20

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/shared.ts ADDED
@@ -0,0 +1,509 @@
1
+ export const VOICE_MAX_TRANSCRIPT_ENTRIES_V1 = 160;
2
+ export const VOICE_MAX_TOOL_CALLS_V1 = 80;
3
+ export const VOICE_MAX_PENDING_ANSWERS_V1 = 32;
4
+ export const VOICE_MAX_SESSIONS_V1 = 24;
5
+ export const VOICE_MAX_ASK_RECORDS_V1 = 160;
6
+ export const VOICE_ANSWER_OUTBOX_MAX_V1 = 32;
7
+
8
+ export type VoiceOfflineReasonV1 =
9
+ "stopped" | "idle" | "quota" | "error" | "replaced";
10
+
11
+ export interface VoiceTranscriptEntryV1 {
12
+ schemaVersion: 1;
13
+ id: string;
14
+ speaker: "user" | "assistant";
15
+ text: string;
16
+ at: string;
17
+ }
18
+
19
+ export interface VoiceToolCallEntryV1 {
20
+ schemaVersion: 1;
21
+ id: string;
22
+ name: VoiceToolNameV1;
23
+ label: string;
24
+ at: string;
25
+ }
26
+
27
+ export interface VoicePendingAnswerV1 {
28
+ schemaVersion: 1;
29
+ answerId: string;
30
+ botId: string;
31
+ botName: string;
32
+ question: string;
33
+ answer: string;
34
+ answeredAt: string;
35
+ briefedAt?: string;
36
+ }
37
+
38
+ export interface VoiceAskEventV1 {
39
+ schemaVersion: 1;
40
+ type: "voice/ask";
41
+ askId: string;
42
+ sessionId: string;
43
+ botId: string;
44
+ botName: string;
45
+ question: string;
46
+ runId: string;
47
+ askedAt: string;
48
+ }
49
+
50
+ export interface VoiceAnsweredEventV1 {
51
+ schemaVersion: 1;
52
+ type: "voice/answered";
53
+ askId: string;
54
+ botId: string;
55
+ runId: string;
56
+ answer: string;
57
+ answeredAt: string;
58
+ }
59
+
60
+ export interface VoiceBriefedEventV1 {
61
+ schemaVersion: 1;
62
+ type: "voice/briefed";
63
+ askId: string;
64
+ sessionId: string;
65
+ briefedAt: string;
66
+ }
67
+
68
+ export interface VoiceAskFailedEventV1 {
69
+ schemaVersion: 1;
70
+ type: "voice/failed";
71
+ askId: string;
72
+ botId: string;
73
+ runId: string;
74
+ reason: string;
75
+ failedAt: string;
76
+ }
77
+
78
+ /** One durable Voice question and the events that have settled it so far. */
79
+ export interface VoiceAskRecordV1 {
80
+ schemaVersion: 1;
81
+ ask: VoiceAskEventV1;
82
+ answered?: VoiceAnsweredEventV1;
83
+ briefed?: VoiceBriefedEventV1;
84
+ failed?: VoiceAskFailedEventV1;
85
+ }
86
+
87
+ /** The Bot Durable Object's idempotent delivery into the User authority. */
88
+ export type VoiceAnswerDeliveryV1 =
89
+ | {
90
+ schemaVersion: 1;
91
+ outcome: "answered";
92
+ userId: string;
93
+ askId: string;
94
+ botId: string;
95
+ runId: string;
96
+ answer: string;
97
+ at: string;
98
+ }
99
+ | {
100
+ schemaVersion: 1;
101
+ outcome: "failed";
102
+ userId: string;
103
+ askId: string;
104
+ botId: string;
105
+ runId: string;
106
+ reason: string;
107
+ at: string;
108
+ };
109
+
110
+ export interface VoiceSessionRecordV1 {
111
+ schemaVersion: 1;
112
+ sessionId: string;
113
+ deviceId: string;
114
+ startedAt: string;
115
+ endedAt?: string;
116
+ endedReason?: VoiceOfflineReasonV1;
117
+ seconds: number;
118
+ transcript: VoiceTranscriptEntryV1[];
119
+ toolCalls: VoiceToolCallEntryV1[];
120
+ }
121
+
122
+ export interface VoiceStateV1 {
123
+ schemaVersion: 1;
124
+ enabled: boolean;
125
+ updatedAt: string;
126
+ activeSessionId?: string;
127
+ activeDeviceId?: string;
128
+ resumptionHandle?: string;
129
+ }
130
+
131
+ export interface VoiceLedgerViewV1 {
132
+ schemaVersion: 1;
133
+ state: VoiceStateV1;
134
+ sessions: VoiceSessionRecordV1[];
135
+ pendingAnswers: VoicePendingAnswerV1[];
136
+ }
137
+
138
+ export interface VoiceAssistantQuotaViewV1 {
139
+ schemaVersion: 1;
140
+ month: string;
141
+ usedSeconds: number;
142
+ limitSeconds: number;
143
+ remainingSeconds: number;
144
+ }
145
+
146
+ export interface VoiceAssistantViewV1 {
147
+ schemaVersion: 1;
148
+ ledger: VoiceLedgerViewV1;
149
+ quota: VoiceAssistantQuotaViewV1;
150
+ }
151
+
152
+ export type VoiceToolNameV1 =
153
+ | "list_bots"
154
+ | "bot_activity"
155
+ | "memory_search"
156
+ | "pending_answers"
157
+ | "ask_bot";
158
+
159
+ function record(input: unknown, label: string): Record<string, unknown> {
160
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
161
+ throw new Error(`${label} must be an object`);
162
+ }
163
+ return input as Record<string, unknown>;
164
+ }
165
+
166
+ function boundedString(input: unknown, label: string, max: number): string {
167
+ if (typeof input !== "string" || input.length === 0 || input.length > max) {
168
+ throw new Error(`${label} must be a bounded string`);
169
+ }
170
+ return input;
171
+ }
172
+
173
+ function optionalString(
174
+ input: unknown,
175
+ label: string,
176
+ max: number,
177
+ ): string | undefined {
178
+ return input === undefined ? undefined : boundedString(input, label, max);
179
+ }
180
+
181
+ export function decodeVoiceStateV1(input: unknown): VoiceStateV1 {
182
+ const value = record(input, "voice state");
183
+ if (value.schemaVersion !== 1 || typeof value.enabled !== "boolean") {
184
+ throw new Error("voice state is invalid");
185
+ }
186
+ return {
187
+ schemaVersion: 1,
188
+ enabled: value.enabled,
189
+ updatedAt: boundedString(value.updatedAt, "voice state.updatedAt", 64),
190
+ ...(optionalString(
191
+ value.activeSessionId,
192
+ "voice state.activeSessionId",
193
+ 128,
194
+ )
195
+ ? { activeSessionId: value.activeSessionId as string }
196
+ : {}),
197
+ ...(optionalString(value.activeDeviceId, "voice state.activeDeviceId", 128)
198
+ ? { activeDeviceId: value.activeDeviceId as string }
199
+ : {}),
200
+ ...(optionalString(
201
+ value.resumptionHandle,
202
+ "voice state.resumptionHandle",
203
+ 16_384,
204
+ )
205
+ ? { resumptionHandle: value.resumptionHandle as string }
206
+ : {}),
207
+ };
208
+ }
209
+
210
+ export function decodeVoiceLedgerViewV1(input: unknown): VoiceLedgerViewV1 {
211
+ const value = record(input, "voice ledger");
212
+ if (value.schemaVersion !== 1) {
213
+ throw new Error("voice ledger.schemaVersion is unsupported");
214
+ }
215
+ if (!Array.isArray(value.sessions) || !Array.isArray(value.pendingAnswers)) {
216
+ throw new Error("voice ledger lists are invalid");
217
+ }
218
+ return {
219
+ schemaVersion: 1,
220
+ state: decodeVoiceStateV1(value.state),
221
+ sessions: value.sessions
222
+ .slice(0, VOICE_MAX_SESSIONS_V1)
223
+ .map(decodeVoiceSessionRecordV1),
224
+ pendingAnswers: value.pendingAnswers
225
+ .slice(0, VOICE_MAX_PENDING_ANSWERS_V1)
226
+ .map(decodeVoicePendingAnswerV1),
227
+ };
228
+ }
229
+
230
+ function timestamp(input: unknown, label: string): string {
231
+ const value = boundedString(input, label, 64);
232
+ if (!Number.isFinite(Date.parse(value)))
233
+ throw new Error(`${label} is invalid`);
234
+ return value;
235
+ }
236
+
237
+ function natural(input: unknown, label: string): number {
238
+ if (!Number.isSafeInteger(input) || (input as number) < 0) {
239
+ throw new Error(`${label} must be a non-negative integer`);
240
+ }
241
+ return input as number;
242
+ }
243
+
244
+ function decodeVoiceTranscriptEntryV1(input: unknown): VoiceTranscriptEntryV1 {
245
+ const value = record(input, "voice transcript entry");
246
+ if (
247
+ value.schemaVersion !== 1 ||
248
+ (value.speaker !== "user" && value.speaker !== "assistant")
249
+ ) {
250
+ throw new Error("voice transcript entry is invalid");
251
+ }
252
+ return {
253
+ schemaVersion: 1,
254
+ id: boundedString(value.id, "voice transcript entry.id", 128),
255
+ speaker: value.speaker,
256
+ text: boundedString(value.text, "voice transcript entry.text", 4_000),
257
+ at: timestamp(value.at, "voice transcript entry.at"),
258
+ };
259
+ }
260
+
261
+ function decodeVoiceToolCallEntryV1(input: unknown): VoiceToolCallEntryV1 {
262
+ const value = record(input, "voice tool call");
263
+ const names: VoiceToolNameV1[] = [
264
+ "list_bots",
265
+ "bot_activity",
266
+ "memory_search",
267
+ "pending_answers",
268
+ "ask_bot",
269
+ ];
270
+ if (
271
+ value.schemaVersion !== 1 ||
272
+ !names.includes(value.name as VoiceToolNameV1)
273
+ ) {
274
+ throw new Error("voice tool call is invalid");
275
+ }
276
+ return {
277
+ schemaVersion: 1,
278
+ id: boundedString(value.id, "voice tool call.id", 128),
279
+ name: value.name as VoiceToolNameV1,
280
+ label: boundedString(value.label, "voice tool call.label", 160),
281
+ at: timestamp(value.at, "voice tool call.at"),
282
+ };
283
+ }
284
+
285
+ function decodeVoiceSessionRecordV1(input: unknown): VoiceSessionRecordV1 {
286
+ const value = record(input, "voice session");
287
+ if (
288
+ value.schemaVersion !== 1 ||
289
+ !Array.isArray(value.transcript) ||
290
+ !Array.isArray(value.toolCalls)
291
+ ) {
292
+ throw new Error("voice session is invalid");
293
+ }
294
+ const reasons: VoiceOfflineReasonV1[] = [
295
+ "stopped",
296
+ "idle",
297
+ "quota",
298
+ "error",
299
+ "replaced",
300
+ ];
301
+ if (
302
+ value.endedReason !== undefined &&
303
+ !reasons.includes(value.endedReason as VoiceOfflineReasonV1)
304
+ ) {
305
+ throw new Error("voice session.endedReason is invalid");
306
+ }
307
+ return {
308
+ schemaVersion: 1,
309
+ sessionId: boundedString(value.sessionId, "voice session.sessionId", 128),
310
+ deviceId: boundedString(value.deviceId, "voice session.deviceId", 128),
311
+ startedAt: timestamp(value.startedAt, "voice session.startedAt"),
312
+ ...(value.endedAt === undefined
313
+ ? {}
314
+ : { endedAt: timestamp(value.endedAt, "voice session.endedAt") }),
315
+ ...(value.endedReason === undefined
316
+ ? {}
317
+ : { endedReason: value.endedReason as VoiceOfflineReasonV1 }),
318
+ seconds: natural(value.seconds, "voice session.seconds"),
319
+ transcript: value.transcript
320
+ .slice(-VOICE_MAX_TRANSCRIPT_ENTRIES_V1)
321
+ .map(decodeVoiceTranscriptEntryV1),
322
+ toolCalls: value.toolCalls
323
+ .slice(-VOICE_MAX_TOOL_CALLS_V1)
324
+ .map(decodeVoiceToolCallEntryV1),
325
+ };
326
+ }
327
+
328
+ export function decodeVoicePendingAnswerV1(
329
+ input: unknown,
330
+ ): VoicePendingAnswerV1 {
331
+ const value = record(input, "voice pending answer");
332
+ if (value.schemaVersion !== 1)
333
+ throw new Error("voice pending answer is invalid");
334
+ return {
335
+ schemaVersion: 1,
336
+ answerId: boundedString(
337
+ value.answerId,
338
+ "voice pending answer.answerId",
339
+ 128,
340
+ ),
341
+ botId: boundedString(value.botId, "voice pending answer.botId", 128),
342
+ botName: boundedString(value.botName, "voice pending answer.botName", 160),
343
+ question: boundedString(
344
+ value.question,
345
+ "voice pending answer.question",
346
+ 2_000,
347
+ ),
348
+ answer: boundedString(value.answer, "voice pending answer.answer", 4_000),
349
+ answeredAt: timestamp(value.answeredAt, "voice pending answer.answeredAt"),
350
+ ...(value.briefedAt === undefined
351
+ ? {}
352
+ : {
353
+ briefedAt: timestamp(
354
+ value.briefedAt,
355
+ "voice pending answer.briefedAt",
356
+ ),
357
+ }),
358
+ };
359
+ }
360
+
361
+ export function decodeVoiceAskRecordV1(input: unknown): VoiceAskRecordV1 {
362
+ const value = record(input, "voice ask record");
363
+ const ask = record(value.ask, "voice ask record.ask");
364
+ if (
365
+ value.schemaVersion !== 1 ||
366
+ ask.schemaVersion !== 1 ||
367
+ ask.type !== "voice/ask"
368
+ ) {
369
+ throw new Error("voice ask record is invalid");
370
+ }
371
+ const decodedAsk: VoiceAskEventV1 = {
372
+ schemaVersion: 1,
373
+ type: "voice/ask",
374
+ askId: boundedString(ask.askId, "voice ask.askId", 128),
375
+ sessionId: boundedString(ask.sessionId, "voice ask.sessionId", 128),
376
+ botId: boundedString(ask.botId, "voice ask.botId", 128),
377
+ botName: boundedString(ask.botName, "voice ask.botName", 160),
378
+ question: boundedString(ask.question, "voice ask.question", 2_000),
379
+ runId: boundedString(ask.runId, "voice ask.runId", 128),
380
+ askedAt: timestamp(ask.askedAt, "voice ask.askedAt"),
381
+ };
382
+ let answered: VoiceAnsweredEventV1 | undefined;
383
+ if (value.answered !== undefined) {
384
+ const event = record(value.answered, "voice ask record.answered");
385
+ if (event.schemaVersion !== 1 || event.type !== "voice/answered") {
386
+ throw new Error("voice answered event is invalid");
387
+ }
388
+ answered = {
389
+ schemaVersion: 1,
390
+ type: "voice/answered",
391
+ askId: boundedString(event.askId, "voice answered.askId", 128),
392
+ botId: boundedString(event.botId, "voice answered.botId", 128),
393
+ runId: boundedString(event.runId, "voice answered.runId", 128),
394
+ answer: boundedString(event.answer, "voice answered.answer", 4_000),
395
+ answeredAt: timestamp(event.answeredAt, "voice answered.answeredAt"),
396
+ };
397
+ }
398
+ let briefed: VoiceBriefedEventV1 | undefined;
399
+ if (value.briefed !== undefined) {
400
+ const event = record(value.briefed, "voice ask record.briefed");
401
+ if (event.schemaVersion !== 1 || event.type !== "voice/briefed") {
402
+ throw new Error("voice briefed event is invalid");
403
+ }
404
+ briefed = {
405
+ schemaVersion: 1,
406
+ type: "voice/briefed",
407
+ askId: boundedString(event.askId, "voice briefed.askId", 128),
408
+ sessionId: boundedString(event.sessionId, "voice briefed.sessionId", 128),
409
+ briefedAt: timestamp(event.briefedAt, "voice briefed.briefedAt"),
410
+ };
411
+ }
412
+ let failed: VoiceAskFailedEventV1 | undefined;
413
+ if (value.failed !== undefined) {
414
+ const event = record(value.failed, "voice ask record.failed");
415
+ if (event.schemaVersion !== 1 || event.type !== "voice/failed") {
416
+ throw new Error("voice failed event is invalid");
417
+ }
418
+ failed = {
419
+ schemaVersion: 1,
420
+ type: "voice/failed",
421
+ askId: boundedString(event.askId, "voice failed.askId", 128),
422
+ botId: boundedString(event.botId, "voice failed.botId", 128),
423
+ runId: boundedString(event.runId, "voice failed.runId", 128),
424
+ reason: boundedString(event.reason, "voice failed.reason", 2_000),
425
+ failedAt: timestamp(event.failedAt, "voice failed.failedAt"),
426
+ };
427
+ }
428
+ for (const event of [answered, briefed, failed]) {
429
+ if (event && event.askId !== decodedAsk.askId) {
430
+ throw new Error("voice ask event identity does not match its record");
431
+ }
432
+ }
433
+ return {
434
+ schemaVersion: 1,
435
+ ask: decodedAsk,
436
+ ...(answered ? { answered } : {}),
437
+ ...(briefed ? { briefed } : {}),
438
+ ...(failed ? { failed } : {}),
439
+ };
440
+ }
441
+
442
+ export function decodeVoiceAnswerDeliveryV1(
443
+ input: unknown,
444
+ ): VoiceAnswerDeliveryV1 {
445
+ const value = record(input, "voice answer delivery");
446
+ if (
447
+ value.schemaVersion !== 1 ||
448
+ (value.outcome !== "answered" && value.outcome !== "failed")
449
+ ) {
450
+ throw new Error("voice answer delivery is invalid");
451
+ }
452
+ const base = {
453
+ schemaVersion: 1 as const,
454
+ userId: boundedString(value.userId, "voice answer delivery.userId", 128),
455
+ askId: boundedString(value.askId, "voice answer delivery.askId", 128),
456
+ botId: boundedString(value.botId, "voice answer delivery.botId", 128),
457
+ runId: boundedString(value.runId, "voice answer delivery.runId", 128),
458
+ at: timestamp(value.at, "voice answer delivery.at"),
459
+ };
460
+ return value.outcome === "answered"
461
+ ? {
462
+ ...base,
463
+ outcome: "answered",
464
+ answer: boundedString(
465
+ value.answer,
466
+ "voice answer delivery.answer",
467
+ 4_000,
468
+ ),
469
+ }
470
+ : {
471
+ ...base,
472
+ outcome: "failed",
473
+ reason: boundedString(
474
+ value.reason,
475
+ "voice answer delivery.reason",
476
+ 2_000,
477
+ ),
478
+ };
479
+ }
480
+
481
+ export function decodeVoiceAssistantViewV1(
482
+ input: unknown,
483
+ ): VoiceAssistantViewV1 {
484
+ const value = record(input, "voice assistant view");
485
+ const quota = record(value.quota, "voice assistant quota");
486
+ if (value.schemaVersion !== 1 || quota.schemaVersion !== 1) {
487
+ throw new Error("voice assistant view version is unsupported");
488
+ }
489
+ return {
490
+ schemaVersion: 1,
491
+ ledger: decodeVoiceLedgerViewV1(value.ledger),
492
+ quota: {
493
+ schemaVersion: 1,
494
+ month: boundedString(quota.month, "voice assistant quota.month", 7),
495
+ usedSeconds: natural(
496
+ quota.usedSeconds,
497
+ "voice assistant quota.usedSeconds",
498
+ ),
499
+ limitSeconds: natural(
500
+ quota.limitSeconds,
501
+ "voice assistant quota.limitSeconds",
502
+ ),
503
+ remainingSeconds: natural(
504
+ quota.remainingSeconds,
505
+ "voice assistant quota.remainingSeconds",
506
+ ),
507
+ },
508
+ };
509
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { executeVoiceToolV1, type VoiceToolHostV1 } from "./tools.js";
3
+
4
+ const AT = "2026-09-04T01:02:03.000Z";
5
+
6
+ function host(): VoiceToolHostV1 {
7
+ return {
8
+ async listBots() {
9
+ return [{ botId: "one", name: "Research", status: "active" }];
10
+ },
11
+ async botActivity(botId, since) {
12
+ return {
13
+ botId,
14
+ since: since ?? "all",
15
+ runs: [],
16
+ tasks: [],
17
+ pendingInbox: 0,
18
+ };
19
+ },
20
+ async memorySearch({ query, botId }) {
21
+ return [
22
+ {
23
+ scope: botId ? "bot" : "user",
24
+ botId,
25
+ path: "memory.md",
26
+ snippet: query,
27
+ score: 1,
28
+ },
29
+ ];
30
+ },
31
+ async pendingAnswers() {
32
+ return [];
33
+ },
34
+ async askBot(input) {
35
+ return { status: "accepted", message: `Asked ${input.bot}` };
36
+ },
37
+ };
38
+ }
39
+
40
+ describe("Voice read-only tools", () => {
41
+ test("executes the declared list tool", async () => {
42
+ expect(
43
+ await executeVoiceToolV1(host(), { name: "list_bots", args: {} }),
44
+ ).toMatchObject({
45
+ name: "list_bots",
46
+ label: "Checked your Bots",
47
+ result: { bots: [{ botId: "one" }] },
48
+ });
49
+ });
50
+
51
+ test("validates arguments at the runtime seam", async () => {
52
+ await expect(
53
+ executeVoiceToolV1(host(), {
54
+ name: "bot_activity",
55
+ args: { bot: "one", since: "yesterday" },
56
+ }),
57
+ ).rejects.toThrow("ISO timestamp");
58
+ await expect(
59
+ executeVoiceToolV1(host(), {
60
+ name: "pending_answers",
61
+ args: { surprise: true },
62
+ }),
63
+ ).rejects.toThrow("arguments");
64
+ });
65
+
66
+ test("executes ask_bot through the same table", async () => {
67
+ expect(
68
+ await executeVoiceToolV1(host(), {
69
+ name: "ask_bot",
70
+ args: { bot: "one", question: "What changed?" },
71
+ context: { sessionId: "voice-1", callId: "call-1", at: AT },
72
+ }),
73
+ ).toMatchObject({
74
+ name: "ask_bot",
75
+ label: "Asked one",
76
+ result: { status: "accepted", message: "Asked one" },
77
+ });
78
+ });
79
+ });