@frockbot/plugin-voice 0.0.0 → 0.3.21

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/ledger.ts ADDED
@@ -0,0 +1,499 @@
1
+ import {
2
+ decodeVoiceAskRecordV1,
3
+ VOICE_MAX_ASK_RECORDS_V1,
4
+ VOICE_MAX_PENDING_ANSWERS_V1,
5
+ VOICE_MAX_SESSIONS_V1,
6
+ VOICE_MAX_TOOL_CALLS_V1,
7
+ VOICE_MAX_TRANSCRIPT_ENTRIES_V1,
8
+ type VoiceAnsweredEventV1,
9
+ type VoiceAskEventV1,
10
+ type VoiceAskFailedEventV1,
11
+ type VoiceAskRecordV1,
12
+ type VoiceBriefedEventV1,
13
+ type VoiceLedgerViewV1,
14
+ type VoiceOfflineReasonV1,
15
+ type VoicePendingAnswerV1,
16
+ type VoiceSessionRecordV1,
17
+ type VoiceStateV1,
18
+ type VoiceToolCallEntryV1,
19
+ type VoiceTranscriptEntryV1,
20
+ } from "./shared.js";
21
+
22
+ export const VOICE_STATE_KEY_V1 = "voice:assistant:state";
23
+ export const VOICE_SESSION_PREFIX_V1 = "voice:assistant:ledger:session:";
24
+ export const VOICE_PENDING_PREFIX_V1 = "voice:assistant:ledger:answer:";
25
+ export const VOICE_ASK_PREFIX_V1 = "voice:assistant:ledger:ask:";
26
+
27
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
28
+
29
+ export interface VoiceLedgerTransactionV1 {
30
+ get<T>(key: string): Promise<T | undefined>;
31
+ put(key: string, value: unknown): Promise<void>;
32
+ delete(key: string): Promise<boolean>;
33
+ list<T>(options: {
34
+ prefix: string;
35
+ limit?: number;
36
+ reverse?: boolean;
37
+ }): Promise<Map<string, T>>;
38
+ }
39
+
40
+ export interface VoiceLedgerStorageV1 extends VoiceLedgerTransactionV1 {
41
+ transaction<T>(
42
+ callback: (storage: VoiceLedgerTransactionV1) => Promise<T>,
43
+ ): Promise<T>;
44
+ }
45
+
46
+ function identifier(value: string, label: string): string {
47
+ if (!IDENTIFIER.test(value)) throw new Error(`${label} is invalid`);
48
+ return value;
49
+ }
50
+
51
+ function timestamp(value: string, label: string): string {
52
+ if (value.length > 64 || !Number.isFinite(Date.parse(value))) {
53
+ throw new Error(`${label} is invalid`);
54
+ }
55
+ return value;
56
+ }
57
+
58
+ function stateOrDefault(
59
+ value: VoiceStateV1 | undefined,
60
+ at: string,
61
+ ): VoiceStateV1 {
62
+ if (value?.schemaVersion === 1 && typeof value.enabled === "boolean") {
63
+ return value;
64
+ }
65
+ return { schemaVersion: 1, enabled: false, updatedAt: at };
66
+ }
67
+
68
+ function sessionKey(sessionId: string): string {
69
+ return `${VOICE_SESSION_PREFIX_V1}${identifier(sessionId, "voice session id")}`;
70
+ }
71
+
72
+ function pendingKey(answerId: string): string {
73
+ return `${VOICE_PENDING_PREFIX_V1}${identifier(answerId, "voice answer id")}`;
74
+ }
75
+
76
+ function askKey(askId: string): string {
77
+ return `${VOICE_ASK_PREFIX_V1}${identifier(askId, "voice ask id")}`;
78
+ }
79
+
80
+ function boundedText(value: string, label: string, max = 4_000): string {
81
+ const text = value.trim();
82
+ if (!text || text.length > max) throw new Error(`${label} is invalid`);
83
+ return text;
84
+ }
85
+
86
+ export class VoiceLedgerV1 {
87
+ constructor(private readonly storage: VoiceLedgerStorageV1) {}
88
+
89
+ async start(input: {
90
+ sessionId: string;
91
+ deviceId: string;
92
+ at: string;
93
+ }): Promise<{ state: VoiceStateV1; replacedSessionId?: string }> {
94
+ const sessionId = identifier(input.sessionId, "voice session id");
95
+ const deviceId = identifier(input.deviceId, "voice device id");
96
+ const at = timestamp(input.at, "voice session time");
97
+ return this.storage.transaction(async (transaction) => {
98
+ const current = stateOrDefault(
99
+ await transaction.get<VoiceStateV1>(VOICE_STATE_KEY_V1),
100
+ at,
101
+ );
102
+ const replacedSessionId =
103
+ current.enabled && current.activeSessionId !== sessionId
104
+ ? current.activeSessionId
105
+ : undefined;
106
+ if (replacedSessionId) {
107
+ const oldKey = sessionKey(replacedSessionId);
108
+ const old = await transaction.get<VoiceSessionRecordV1>(oldKey);
109
+ if (old && !old.endedAt) {
110
+ await transaction.put(oldKey, {
111
+ ...old,
112
+ endedAt: at,
113
+ endedReason: "replaced",
114
+ } satisfies VoiceSessionRecordV1);
115
+ }
116
+ }
117
+ const key = sessionKey(sessionId);
118
+ if ((await transaction.get(key)) === undefined) {
119
+ await transaction.put(key, {
120
+ schemaVersion: 1,
121
+ sessionId,
122
+ deviceId,
123
+ startedAt: at,
124
+ seconds: 0,
125
+ transcript: [],
126
+ toolCalls: [],
127
+ } satisfies VoiceSessionRecordV1);
128
+ }
129
+ const sessions = await transaction.list<VoiceSessionRecordV1>({
130
+ prefix: VOICE_SESSION_PREFIX_V1,
131
+ limit: VOICE_MAX_SESSIONS_V1 + 1,
132
+ });
133
+ const expired = [...sessions.entries()]
134
+ .sort((left, right) =>
135
+ right[1].startedAt.localeCompare(left[1].startedAt),
136
+ )
137
+ .slice(VOICE_MAX_SESSIONS_V1);
138
+ for (const [expiredKey] of expired) {
139
+ await transaction.delete(expiredKey);
140
+ }
141
+ const state: VoiceStateV1 = {
142
+ schemaVersion: 1,
143
+ enabled: true,
144
+ updatedAt: at,
145
+ activeSessionId: sessionId,
146
+ activeDeviceId: deviceId,
147
+ ...(current.resumptionHandle
148
+ ? { resumptionHandle: current.resumptionHandle }
149
+ : {}),
150
+ };
151
+ await transaction.put(VOICE_STATE_KEY_V1, state);
152
+ return { state, ...(replacedSessionId ? { replacedSessionId } : {}) };
153
+ });
154
+ }
155
+
156
+ async end(input: {
157
+ sessionId: string;
158
+ at: string;
159
+ reason: VoiceOfflineReasonV1;
160
+ seconds: number;
161
+ }): Promise<VoiceStateV1> {
162
+ const key = sessionKey(input.sessionId);
163
+ const at = timestamp(input.at, "voice session time");
164
+ return this.storage.transaction(async (transaction) => {
165
+ const current = stateOrDefault(
166
+ await transaction.get<VoiceStateV1>(VOICE_STATE_KEY_V1),
167
+ at,
168
+ );
169
+ const session = await transaction.get<VoiceSessionRecordV1>(key);
170
+ if (session && !session.endedAt) {
171
+ await transaction.put(key, {
172
+ ...session,
173
+ endedAt: at,
174
+ endedReason: input.reason,
175
+ seconds: Math.max(
176
+ session.seconds,
177
+ Number.isSafeInteger(input.seconds) ? input.seconds : 0,
178
+ ),
179
+ } satisfies VoiceSessionRecordV1);
180
+ }
181
+ if (current.activeSessionId !== input.sessionId) return current;
182
+ const state: VoiceStateV1 = {
183
+ schemaVersion: 1,
184
+ enabled: false,
185
+ updatedAt: at,
186
+ ...(current.resumptionHandle
187
+ ? { resumptionHandle: current.resumptionHandle }
188
+ : {}),
189
+ };
190
+ await transaction.put(VOICE_STATE_KEY_V1, state);
191
+ return state;
192
+ });
193
+ }
194
+
195
+ async saveResumptionHandle(input: {
196
+ sessionId: string;
197
+ handle: string;
198
+ at: string;
199
+ }): Promise<void> {
200
+ if (input.handle.length === 0 || input.handle.length > 16_384) {
201
+ throw new Error("voice resumption handle is invalid");
202
+ }
203
+ await this.storage.transaction(async (transaction) => {
204
+ const current = stateOrDefault(
205
+ await transaction.get<VoiceStateV1>(VOICE_STATE_KEY_V1),
206
+ timestamp(input.at, "voice session time"),
207
+ );
208
+ if (current.activeSessionId !== input.sessionId) return;
209
+ await transaction.put(VOICE_STATE_KEY_V1, {
210
+ ...current,
211
+ updatedAt: input.at,
212
+ resumptionHandle: input.handle,
213
+ } satisfies VoiceStateV1);
214
+ });
215
+ }
216
+
217
+ async appendTranscript(
218
+ sessionId: string,
219
+ entry: VoiceTranscriptEntryV1,
220
+ ): Promise<void> {
221
+ const key = sessionKey(sessionId);
222
+ await this.storage.transaction(async (transaction) => {
223
+ const session = await transaction.get<VoiceSessionRecordV1>(key);
224
+ if (!session || session.transcript.some((held) => held.id === entry.id)) {
225
+ return;
226
+ }
227
+ await transaction.put(key, {
228
+ ...session,
229
+ transcript: [
230
+ ...session.transcript,
231
+ {
232
+ ...entry,
233
+ text: boundedText(entry.text, "voice transcript text"),
234
+ },
235
+ ].slice(-VOICE_MAX_TRANSCRIPT_ENTRIES_V1),
236
+ } satisfies VoiceSessionRecordV1);
237
+ });
238
+ }
239
+
240
+ async appendToolCall(
241
+ sessionId: string,
242
+ entry: VoiceToolCallEntryV1,
243
+ ): Promise<void> {
244
+ const key = sessionKey(sessionId);
245
+ await this.storage.transaction(async (transaction) => {
246
+ const session = await transaction.get<VoiceSessionRecordV1>(key);
247
+ if (!session || session.toolCalls.some((held) => held.id === entry.id)) {
248
+ return;
249
+ }
250
+ await transaction.put(key, {
251
+ ...session,
252
+ toolCalls: [...session.toolCalls, entry].slice(
253
+ -VOICE_MAX_TOOL_CALLS_V1,
254
+ ),
255
+ } satisfies VoiceSessionRecordV1);
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Records intent before agent admission and enforces Voice's two bounds.
261
+ * A replay of the same ask is admitted; reuse of its id with different
262
+ * content is rejected rather than silently changing the durable intent.
263
+ */
264
+ async recordAsk(
265
+ input: VoiceAskEventV1,
266
+ ): Promise<
267
+ | { status: "recorded" | "replayed"; record: VoiceAskRecordV1 }
268
+ | { status: "refused"; reason: string }
269
+ > {
270
+ const record = decodeVoiceAskRecordV1({ schemaVersion: 1, ask: input });
271
+ const key = askKey(record.ask.askId);
272
+ return this.storage.transaction(async (transaction) => {
273
+ const held = await transaction.get<unknown>(key);
274
+ if (held !== undefined) {
275
+ const existing = decodeVoiceAskRecordV1(held);
276
+ if (
277
+ existing.ask.sessionId !== record.ask.sessionId ||
278
+ existing.ask.botId !== record.ask.botId ||
279
+ existing.ask.botName !== record.ask.botName ||
280
+ existing.ask.question !== record.ask.question ||
281
+ existing.ask.runId !== record.ask.runId
282
+ ) {
283
+ throw new Error("voice ask id was reused for different content");
284
+ }
285
+ return { status: "replayed" as const, record: existing };
286
+ }
287
+ const [storedAsks, storedAnswers] = await Promise.all([
288
+ transaction.list<unknown>({
289
+ prefix: VOICE_ASK_PREFIX_V1,
290
+ limit: VOICE_MAX_ASK_RECORDS_V1 + 1,
291
+ }),
292
+ transaction.list<VoicePendingAnswerV1>({
293
+ prefix: VOICE_PENDING_PREFIX_V1,
294
+ limit: VOICE_MAX_PENDING_ANSWERS_V1,
295
+ }),
296
+ ]);
297
+ const asks = [...storedAsks.entries()].map(([storedKey, value]) => [
298
+ storedKey,
299
+ decodeVoiceAskRecordV1(value),
300
+ ]) as Array<[string, VoiceAskRecordV1]>;
301
+ if (
302
+ asks.some(
303
+ ([, candidate]) =>
304
+ candidate.ask.sessionId === record.ask.sessionId &&
305
+ candidate.ask.botId === record.ask.botId &&
306
+ !candidate.answered &&
307
+ !candidate.failed,
308
+ )
309
+ ) {
310
+ return {
311
+ status: "refused" as const,
312
+ reason: `${record.ask.botName} is already answering a Voice question from this session.`,
313
+ };
314
+ }
315
+ const unbriefed = [...storedAnswers.values()].filter(
316
+ (answer) => !answer.briefedAt,
317
+ ).length;
318
+ const unanswered = asks.filter(
319
+ ([, candidate]) => !candidate.answered && !candidate.failed,
320
+ ).length;
321
+ if (unbriefed + unanswered >= VOICE_MAX_PENDING_ANSWERS_V1) {
322
+ return {
323
+ status: "refused" as const,
324
+ reason: "Voice already has 32 Bot answers waiting or on the way.",
325
+ };
326
+ }
327
+ if (asks.length >= VOICE_MAX_ASK_RECORDS_V1) {
328
+ const removable = asks
329
+ .filter(([, candidate]) => candidate.failed || candidate.briefed)
330
+ .sort((left, right) =>
331
+ left[1].ask.askedAt.localeCompare(right[1].ask.askedAt),
332
+ );
333
+ const needed = asks.length - VOICE_MAX_ASK_RECORDS_V1 + 1;
334
+ if (removable.length < needed) {
335
+ return {
336
+ status: "refused" as const,
337
+ reason: "Voice already has too many Bot questions in progress.",
338
+ };
339
+ }
340
+ for (const [expiredKey] of removable.slice(0, needed)) {
341
+ await transaction.delete(expiredKey);
342
+ }
343
+ }
344
+ await transaction.put(key, record);
345
+ return { status: "recorded" as const, record };
346
+ });
347
+ }
348
+
349
+ async readAsk(askId: string): Promise<VoiceAskRecordV1 | undefined> {
350
+ const stored = await this.storage.get<unknown>(askKey(askId));
351
+ return stored === undefined ? undefined : decodeVoiceAskRecordV1(stored);
352
+ }
353
+
354
+ /** `voice/answered` and its pending projection commit atomically. */
355
+ async recordAnswered(
356
+ event: VoiceAnsweredEventV1,
357
+ ): Promise<{ record: VoiceAskRecordV1; answer: VoicePendingAnswerV1 }> {
358
+ const key = askKey(event.askId);
359
+ return this.storage.transaction(async (transaction) => {
360
+ const stored = await transaction.get<unknown>(key);
361
+ if (stored === undefined) throw new Error("voice ask was not found");
362
+ const current = decodeVoiceAskRecordV1(stored);
363
+ if (
364
+ current.ask.botId !== event.botId ||
365
+ current.ask.runId !== event.runId
366
+ ) {
367
+ throw new Error("voice answer does not match its ask");
368
+ }
369
+ const settledEvent = current.answered ?? event;
370
+ const answered = decodeVoiceAskRecordV1({
371
+ ...current,
372
+ answered: settledEvent,
373
+ failed: undefined,
374
+ });
375
+ const answer: VoicePendingAnswerV1 = {
376
+ schemaVersion: 1,
377
+ answerId: current.ask.askId,
378
+ botId: current.ask.botId,
379
+ botName: current.ask.botName,
380
+ question: current.ask.question,
381
+ answer: answered.answered!.answer,
382
+ answeredAt: answered.answered!.answeredAt,
383
+ ...(answered.briefed ? { briefedAt: answered.briefed.briefedAt } : {}),
384
+ };
385
+ await transaction.put(key, answered);
386
+ await transaction.put(pendingKey(answer.answerId), answer);
387
+ return { record: answered, answer };
388
+ });
389
+ }
390
+
391
+ async recordFailed(event: VoiceAskFailedEventV1): Promise<VoiceAskRecordV1> {
392
+ const key = askKey(event.askId);
393
+ return this.storage.transaction(async (transaction) => {
394
+ const stored = await transaction.get<unknown>(key);
395
+ if (stored === undefined) throw new Error("voice ask was not found");
396
+ const current = decodeVoiceAskRecordV1(stored);
397
+ if (
398
+ current.ask.botId !== event.botId ||
399
+ current.ask.runId !== event.runId
400
+ ) {
401
+ throw new Error("voice failure does not match its ask");
402
+ }
403
+ if (current.answered || current.failed) return current;
404
+ const failed = decodeVoiceAskRecordV1({ ...current, failed: event });
405
+ await transaction.put(key, failed);
406
+ return failed;
407
+ });
408
+ }
409
+
410
+ /** A provider turn is acknowledged only after its speech turn completes. */
411
+ async markBriefed(input: {
412
+ askIds: readonly string[];
413
+ sessionId: string;
414
+ at: string;
415
+ }): Promise<number> {
416
+ const sessionId = identifier(input.sessionId, "voice session id");
417
+ const briefedAt = timestamp(input.at, "voice briefing time");
418
+ return this.storage.transaction(async (transaction) => {
419
+ let changed = 0;
420
+ for (const askId of [...new Set(input.askIds)].slice(
421
+ 0,
422
+ VOICE_MAX_PENDING_ANSWERS_V1,
423
+ )) {
424
+ const key = askKey(askId);
425
+ const stored = await transaction.get<unknown>(key);
426
+ if (stored === undefined) continue;
427
+ const current = decodeVoiceAskRecordV1(stored);
428
+ if (!current.answered || current.briefed) continue;
429
+ const briefed: VoiceBriefedEventV1 = {
430
+ schemaVersion: 1,
431
+ type: "voice/briefed",
432
+ askId: current.ask.askId,
433
+ sessionId,
434
+ briefedAt,
435
+ };
436
+ await transaction.put(
437
+ key,
438
+ decodeVoiceAskRecordV1({ ...current, briefed }),
439
+ );
440
+ const answerKey = pendingKey(current.ask.askId);
441
+ const answer = await transaction.get<VoicePendingAnswerV1>(answerKey);
442
+ if (answer) {
443
+ await transaction.put(answerKey, { ...answer, briefedAt });
444
+ }
445
+ changed += 1;
446
+ }
447
+ return changed;
448
+ });
449
+ }
450
+
451
+ /** B2 writes through this idempotent seam after a Bot answers. */
452
+ async recordPendingAnswer(answer: VoicePendingAnswerV1): Promise<void> {
453
+ const key = pendingKey(answer.answerId);
454
+ await this.storage.transaction(async (transaction) => {
455
+ if ((await transaction.get(key)) === undefined) {
456
+ await transaction.put(key, answer);
457
+ }
458
+ const answers = await transaction.list<VoicePendingAnswerV1>({
459
+ prefix: VOICE_PENDING_PREFIX_V1,
460
+ limit: VOICE_MAX_PENDING_ANSWERS_V1 + 1,
461
+ });
462
+ const expired = [...answers.entries()]
463
+ .sort((left, right) =>
464
+ right[1].answeredAt.localeCompare(left[1].answeredAt),
465
+ )
466
+ .slice(VOICE_MAX_PENDING_ANSWERS_V1);
467
+ for (const [expiredKey] of expired) {
468
+ await transaction.delete(expiredKey);
469
+ }
470
+ });
471
+ }
472
+
473
+ async view(at = new Date().toISOString()): Promise<VoiceLedgerViewV1> {
474
+ const [state, sessions, pending] = await Promise.all([
475
+ this.storage.get<VoiceStateV1>(VOICE_STATE_KEY_V1),
476
+ this.storage.list<VoiceSessionRecordV1>({
477
+ prefix: VOICE_SESSION_PREFIX_V1,
478
+ limit: VOICE_MAX_SESSIONS_V1,
479
+ reverse: true,
480
+ }),
481
+ this.storage.list<VoicePendingAnswerV1>({
482
+ prefix: VOICE_PENDING_PREFIX_V1,
483
+ limit: VOICE_MAX_PENDING_ANSWERS_V1,
484
+ reverse: true,
485
+ }),
486
+ ]);
487
+ return {
488
+ schemaVersion: 1,
489
+ state: stateOrDefault(state, at),
490
+ sessions: [...sessions.values()]
491
+ .sort((left, right) => right.startedAt.localeCompare(left.startedAt))
492
+ .slice(0, VOICE_MAX_SESSIONS_V1),
493
+ pendingAnswers: [...pending.values()]
494
+ .filter((answer) => !answer.briefedAt)
495
+ .sort((left, right) => left.answeredAt.localeCompare(right.answeredAt))
496
+ .slice(0, VOICE_MAX_PENDING_ANSWERS_V1),
497
+ };
498
+ }
499
+ }
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;
package/src/prompt.ts ADDED
@@ -0,0 +1,18 @@
1
+ export const VOICE_GEMINI_MODEL_V1 = "gemini-3.1-flash-live-preview";
2
+ export const VOICE_INPUT_SAMPLE_RATE_V1 = 16_000;
3
+ export const VOICE_OUTPUT_SAMPLE_RATE_V1 = 24_000;
4
+ export const VOICE_IDLE_TIMEOUT_MS_V1 = 2 * 60_000;
5
+
6
+ export function voiceSystemInstructionV1(): string {
7
+ return [
8
+ "You are the User's FrockBot voice assistant.",
9
+ "You can read their Bots, each Bot's recent activity, and durable User and Bot memory using the tools provided.",
10
+ "You can use ask_bot to ask one active Bot a question on the User's behalf. It returns immediately: say the returned message naturally and never wait for the Bot in the current turn.",
11
+ "You cannot change Bot configuration or write memory. Say that plainly when asked.",
12
+ "Keep spoken answers brief and natural. Use tools when facts may have changed; never invent activity or memory.",
13
+ "At the start, check pending_answers. If any answers are waiting, speak those before a greeting or anything else.",
14
+ ].join("\n");
15
+ }
16
+
17
+ export const VOICE_KICKOFF_TEXT_V1 =
18
+ "Check pending_answers now. Speak any waiting answers first; otherwise greet me in one short sentence and listen.";