@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/frockbot.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "schemaVersion": 4,
3
+ "id": "voice",
4
+ "displayName": "Voice",
5
+ "version": "0.0.1",
6
+ "compatibility": { "frockbot": ">=0.0.1" },
7
+ "dependencies": {
8
+ "flock": ">=0.0.1",
9
+ "memory": ">=0.0.1",
10
+ "shell": ">=0.0.1",
11
+ "ui-theme": ">=0.0.1"
12
+ },
13
+ "contributions": {
14
+ "backend": [
15
+ { "entry": "./user", "host": "user" },
16
+ { "entry": "./backend", "host": "gateway" }
17
+ ],
18
+ "client": {
19
+ "entry": "./client",
20
+ "mounts": [{ "slot": "frockbot.header-actions", "order": 20 }],
21
+ "outlets": []
22
+ }
23
+ },
24
+ "permissions": []
25
+ }
package/package.json CHANGED
@@ -1,14 +1,53 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-voice",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.3.21",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./ask": "./src/ask.ts",
9
+ "./backend": "./src/backend.ts",
10
+ "./bot": "./src/bot.ts",
11
+ "./client": "./src/client/index.ts",
12
+ "./frockbot.json": "./frockbot.json",
13
+ "./ledger": "./src/ledger.ts",
14
+ "./manifest": "./src/manifest.ts",
15
+ "./package.json": "./package.json",
16
+ "./prompt": "./src/prompt.ts",
17
+ "./shared": "./src/shared.ts",
18
+ "./tools": "./src/tools.ts",
19
+ "./user": "./src/user.ts"
20
+ },
21
+ "frockbot": {
22
+ "manifest": "./frockbot.json"
23
+ },
24
+ "scripts": {
25
+ "test": "bun test src",
26
+ "typecheck": "vue-tsc --noEmit -p tsconfig.json"
27
+ },
28
+ "dependencies": {
29
+ "@frockbot/client-core": "0.3.21",
30
+ "@frockbot/client-ui": "0.3.21",
31
+ "@frockbot/kernel-contracts": "0.3.21",
32
+ "@frockbot/plugin-flock": "0.3.21",
33
+ "@frockbot/plugin-shell": "0.3.21",
34
+ "@frockbot/protocol": "0.3.21",
35
+ "cordis": "4.0.0-rc.8",
36
+ "vue": "3.5.41"
37
+ },
38
+ "devDependencies": {
39
+ "@types/bun": "1.4.0",
40
+ "@vitejs/plugin-vue": "6.0.8",
41
+ "typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2",
42
+ "vite": "8.2.2",
43
+ "vue-tsc": "3.3.10"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
6
48
  "repository": {
7
49
  "type": "git",
8
50
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
51
  "directory": "packages/plugin-voice"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
52
  }
14
53
  }
@@ -0,0 +1,129 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { askBotFromVoiceV1, type VoiceAskHostV1 } from "./ask.js";
3
+ import { VoiceLedgerV1, type VoiceLedgerStorageV1 } from "./ledger.js";
4
+
5
+ function memoryStorage(): VoiceLedgerStorageV1 {
6
+ const values = new Map<string, unknown>();
7
+ const surface = {
8
+ get: <T>(key: string) => Promise.resolve(values.get(key) as T | undefined),
9
+ put: (key: string, value: unknown) => {
10
+ values.set(key, value);
11
+ return Promise.resolve();
12
+ },
13
+ delete: (key: string) => Promise.resolve(values.delete(key)),
14
+ list: async <T>({ prefix }: { prefix: string }) =>
15
+ new Map(
16
+ [...values]
17
+ .filter(([key]) => key.startsWith(prefix))
18
+ .map(([key, value]) => [key, value as T]),
19
+ ),
20
+ };
21
+ return { ...surface, transaction: (callback) => callback(surface) };
22
+ }
23
+
24
+ describe("Voice ask coordinator", () => {
25
+ test("a replay reaches one idempotent target Turn", async () => {
26
+ const ledger = new VoiceLedgerV1(memoryStorage());
27
+ const admitted = new Set<string>();
28
+ let targetTurns = 0;
29
+ const deferred: Promise<void>[] = [];
30
+ const host: VoiceAskHostV1 = {
31
+ listBots: () =>
32
+ Promise.resolve([
33
+ { botId: "research", name: "Research", status: "active" },
34
+ ]),
35
+ reserveAgentTurn: (request) =>
36
+ Promise.resolve({
37
+ schemaVersion: 1,
38
+ status: "reserved",
39
+ requesterId: request.requesterId,
40
+ runId: request.runId,
41
+ held: 1,
42
+ limit: 8,
43
+ }),
44
+ releaseAgentTurn: () => Promise.resolve(),
45
+ runAgent: async (request) => {
46
+ if (!admitted.has(request.command.runId)) {
47
+ admitted.add(request.command.runId);
48
+ targetTurns += 1;
49
+ }
50
+ },
51
+ defer(task) {
52
+ deferred.push(task);
53
+ },
54
+ };
55
+ const input = {
56
+ userId: "user-1",
57
+ sessionId: "voice-1",
58
+ callId: "call-1",
59
+ bot: "Research",
60
+ question: "What changed?",
61
+ at: "2026-09-04T01:02:03.000Z",
62
+ };
63
+
64
+ const first = await askBotFromVoiceV1(ledger, host, input);
65
+ const replay = await askBotFromVoiceV1(ledger, host, {
66
+ ...input,
67
+ // Gemini can redeliver the same function-call id in a later frame. The
68
+ // original durable ask time is retained while target admission reuses
69
+ // the same run id.
70
+ at: "2026-09-04T01:02:04.000Z",
71
+ });
72
+ await Promise.all(deferred);
73
+
74
+ expect(first).toEqual(replay);
75
+ expect(first).toMatchObject({
76
+ status: "accepted",
77
+ message: "I've asked Research. I'll tell you when Research answers.",
78
+ });
79
+ expect(targetTurns).toBe(1);
80
+ });
81
+
82
+ test("refuses a second outstanding ask to the same Bot and session", async () => {
83
+ const ledger = new VoiceLedgerV1(memoryStorage());
84
+ const deferred: Promise<void>[] = [];
85
+ const host: VoiceAskHostV1 = {
86
+ listBots: () =>
87
+ Promise.resolve([
88
+ { botId: "research", name: "Research", status: "active" },
89
+ ]),
90
+ reserveAgentTurn: (request) =>
91
+ Promise.resolve({
92
+ schemaVersion: 1,
93
+ status: "reserved",
94
+ requesterId: request.requesterId,
95
+ runId: request.runId,
96
+ held: 1,
97
+ limit: 8,
98
+ }),
99
+ releaseAgentTurn: () => Promise.resolve(),
100
+ runAgent: () => new Promise(() => {}),
101
+ defer(task) {
102
+ deferred.push(task);
103
+ },
104
+ };
105
+ await askBotFromVoiceV1(ledger, host, {
106
+ userId: "user-1",
107
+ sessionId: "voice-1",
108
+ callId: "call-1",
109
+ bot: "research",
110
+ question: "First?",
111
+ at: "2026-09-04T01:02:03.000Z",
112
+ });
113
+
114
+ await expect(
115
+ askBotFromVoiceV1(ledger, host, {
116
+ userId: "user-1",
117
+ sessionId: "voice-1",
118
+ callId: "call-2",
119
+ bot: "research",
120
+ question: "Second?",
121
+ at: "2026-09-04T01:02:04.000Z",
122
+ }),
123
+ ).resolves.toEqual({
124
+ status: "refused",
125
+ message:
126
+ "Research is already answering a Voice question from this session.",
127
+ });
128
+ });
129
+ });
package/src/ask.ts ADDED
@@ -0,0 +1,191 @@
1
+ import type { AgentTurnSlotReceiptV1 } from "@frockbot/plugin-flock/quota";
2
+ import type { VoiceLedgerV1 } from "./ledger.js";
3
+ import type { VoiceBotSummaryV1 } from "./tools.js";
4
+
5
+ export interface VoiceAskHostV1 {
6
+ listBots(): Promise<readonly VoiceBotSummaryV1[]>;
7
+ reserveAgentTurn(request: {
8
+ schemaVersion: 1;
9
+ userId: string;
10
+ requesterId: string;
11
+ runId: string;
12
+ reservedAt: string;
13
+ }): Promise<AgentTurnSlotReceiptV1>;
14
+ releaseAgentTurn(request: {
15
+ schemaVersion: 1;
16
+ userId: string;
17
+ requesterId: string;
18
+ runId: string;
19
+ }): Promise<void>;
20
+ runAgent(request: {
21
+ schemaVersion: 1;
22
+ userId: string;
23
+ botId: string;
24
+ command: {
25
+ runId: string;
26
+ sessionId: string;
27
+ acceptedAt: string;
28
+ text: string;
29
+ source: { kind: "voice"; messageId: string };
30
+ };
31
+ }): Promise<unknown>;
32
+ defer(task: Promise<void>): void;
33
+ }
34
+
35
+ export type VoiceAskResultV1 =
36
+ | {
37
+ status: "accepted";
38
+ message: string;
39
+ askId: string;
40
+ runId: string;
41
+ botId: string;
42
+ botName: string;
43
+ }
44
+ | { status: "refused"; message: string };
45
+
46
+ async function digestIdV1(prefix: string, parts: readonly string[]) {
47
+ const digest = await crypto.subtle.digest(
48
+ "SHA-256",
49
+ new TextEncoder().encode(parts.join("\u0000")),
50
+ );
51
+ const hex = [...new Uint8Array(digest)]
52
+ .map((byte) => byte.toString(16).padStart(2, "0"))
53
+ .join("");
54
+ return `${prefix}-${hex.slice(0, 32)}`;
55
+ }
56
+
57
+ function botNamed(
58
+ bots: readonly VoiceBotSummaryV1[],
59
+ requested: string,
60
+ ): VoiceBotSummaryV1 | undefined {
61
+ return (
62
+ bots.find((bot) => bot.botId === requested) ??
63
+ bots.find((bot) => bot.name.toLowerCase() === requested.toLowerCase())
64
+ );
65
+ }
66
+
67
+ /**
68
+ * User-authority coordinator for `ask_bot`.
69
+ *
70
+ * It returns after durable intent and admission scheduling, never after the
71
+ * Bot's answer. A repeated Gemini function call derives the same ids and may
72
+ * safely call the target again: the target Bot's run admission is the
73
+ * idempotency fence, so a crash between this object and that fence cannot
74
+ * become either a lost ask or a second Turn.
75
+ */
76
+ export async function askBotFromVoiceV1(
77
+ ledger: VoiceLedgerV1,
78
+ host: VoiceAskHostV1,
79
+ input: {
80
+ userId: string;
81
+ sessionId: string;
82
+ callId: string;
83
+ bot: string;
84
+ question: string;
85
+ at: string;
86
+ },
87
+ ): Promise<VoiceAskResultV1> {
88
+ const target = botNamed(await host.listBots(), input.bot);
89
+ if (!target || target.status !== "active") {
90
+ return {
91
+ status: "refused",
92
+ message: `I can't ask ${input.bot} because that Bot isn't active.`,
93
+ };
94
+ }
95
+ const askId = await digestIdV1("voice-ask", [
96
+ input.userId,
97
+ input.sessionId,
98
+ input.callId,
99
+ ]);
100
+ const runId = await digestIdV1("agent", [input.userId, target.botId, askId]);
101
+ const requesterId = `voice-${input.sessionId}`;
102
+ const recorded = await ledger.recordAsk({
103
+ schemaVersion: 1,
104
+ type: "voice/ask",
105
+ askId,
106
+ sessionId: input.sessionId,
107
+ botId: target.botId,
108
+ botName: target.name,
109
+ question: input.question,
110
+ runId,
111
+ askedAt: input.at,
112
+ });
113
+ if (recorded.status === "refused") {
114
+ return { status: "refused", message: recorded.reason };
115
+ }
116
+ if (recorded.record.failed) {
117
+ return { status: "refused", message: recorded.record.failed.reason };
118
+ }
119
+ const accepted = {
120
+ status: "accepted" as const,
121
+ message: `I've asked ${target.name}. I'll tell you when ${target.name} answers.`,
122
+ askId,
123
+ runId,
124
+ botId: target.botId,
125
+ botName: target.name,
126
+ };
127
+ if (recorded.record.answered) return accepted;
128
+
129
+ const reservation = await host.reserveAgentTurn({
130
+ schemaVersion: 1,
131
+ userId: input.userId,
132
+ requesterId,
133
+ runId,
134
+ reservedAt: input.at,
135
+ });
136
+ if (reservation.status === "refused") {
137
+ const message =
138
+ "I can't ask another Bot right now because eight agent requests are already running.";
139
+ await ledger.recordFailed({
140
+ schemaVersion: 1,
141
+ type: "voice/failed",
142
+ askId,
143
+ botId: target.botId,
144
+ runId,
145
+ reason: message,
146
+ failedAt: input.at,
147
+ });
148
+ return { status: "refused", message };
149
+ }
150
+
151
+ host.defer(
152
+ host
153
+ .runAgent({
154
+ schemaVersion: 1,
155
+ userId: input.userId,
156
+ botId: target.botId,
157
+ command: {
158
+ runId,
159
+ sessionId: `${input.userId}:${target.botId}`,
160
+ acceptedAt: input.at,
161
+ text: input.question,
162
+ source: { kind: "voice", messageId: askId },
163
+ },
164
+ })
165
+ .then(() => undefined)
166
+ .catch(async (error) => {
167
+ const message = `I couldn't get an answer from ${target.name}: ${
168
+ error instanceof Error ? error.message : "the Bot run failed"
169
+ }`.slice(0, 2_000);
170
+ try {
171
+ await ledger.recordFailed({
172
+ schemaVersion: 1,
173
+ type: "voice/failed",
174
+ askId,
175
+ botId: target.botId,
176
+ runId,
177
+ reason: message,
178
+ failedAt: new Date().toISOString(),
179
+ });
180
+ } finally {
181
+ await host.releaseAgentTurn({
182
+ schemaVersion: 1,
183
+ userId: input.userId,
184
+ requesterId,
185
+ runId,
186
+ });
187
+ }
188
+ }),
189
+ );
190
+ return accepted;
191
+ }
package/src/backend.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { defineGatewayContribution } from "@frockbot/kernel-contracts/contributions";
2
+ import type { Plugin } from "cordis";
3
+
4
+ export interface VoiceGatewayHostV1 {
5
+ readVoiceAssistant(userId: string): Promise<unknown>;
6
+ openVoiceAssistant(userId: string, request: Request): Promise<Response>;
7
+ }
8
+
9
+ export interface VoiceBackendRouteContributionV1 {
10
+ packageId: "voice";
11
+ route(
12
+ request: Request,
13
+ url: URL,
14
+ context: { userId?: string; client: "browser" | "desktop" },
15
+ ): Promise<Response | undefined>;
16
+ }
17
+
18
+ export function createVoiceBackendContributionV1(
19
+ host: VoiceGatewayHostV1,
20
+ ): VoiceBackendRouteContributionV1 {
21
+ return {
22
+ packageId: "voice",
23
+ async route(request, url, context) {
24
+ if (!context.userId) return undefined;
25
+ if (url.pathname === "/api/voice") {
26
+ if (request.method !== "GET") {
27
+ return Response.json(
28
+ { error: "method not allowed" },
29
+ { status: 405 },
30
+ );
31
+ }
32
+ return Response.json(await host.readVoiceAssistant(context.userId));
33
+ }
34
+ if (url.pathname === "/api/voice/assistant") {
35
+ if (request.method !== "GET") {
36
+ return Response.json(
37
+ { error: "method not allowed" },
38
+ { status: 405 },
39
+ );
40
+ }
41
+ return host.openVoiceAssistant(context.userId, request);
42
+ }
43
+ return undefined;
44
+ },
45
+ };
46
+ }
47
+
48
+ export namespace createVoiceBackendContributionV1 {
49
+ export function plugin(
50
+ host: VoiceGatewayHostV1,
51
+ lifecycle: { mount(value: VoiceBackendRouteContributionV1): () => void },
52
+ ): Plugin {
53
+ return () => lifecycle.mount(createVoiceBackendContributionV1(host));
54
+ }
55
+ }
56
+
57
+ export const backendContribution = defineGatewayContribution<
58
+ VoiceGatewayHostV1,
59
+ VoiceBackendRouteContributionV1
60
+ >({
61
+ specifier: "@frockbot/plugin-voice/backend",
62
+ create: createVoiceBackendContributionV1.plugin,
63
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { SessionEvent } from "@frockbot/kernel-contracts";
3
+ import { VoiceAnswerOutboxV1, voiceAnswerFromSettledTurnV1 } from "./bot.js";
4
+
5
+ describe("Voice Bot answer projection", () => {
6
+ test("takes the first text send from the settled Voice Turn", () => {
7
+ const delivery = voiceAnswerFromSettledTurnV1({
8
+ userId: "user-1",
9
+ botId: "research",
10
+ runId: "agent-1",
11
+ turn: 2,
12
+ origin: { kind: "voice", messageId: "ask-1" },
13
+ events: [
14
+ {
15
+ type: "send/to-user",
16
+ seq: 1,
17
+ timestamp: "2026-09-04T01:02:03.000Z",
18
+ turn: 2,
19
+ step: 1,
20
+ occurrenceId: "2:1:0",
21
+ payload: { type: "text", text: "First answer." },
22
+ },
23
+ {
24
+ type: "send/to-user",
25
+ seq: 2,
26
+ timestamp: "2026-09-04T01:02:04.000Z",
27
+ turn: 2,
28
+ step: 2,
29
+ occurrenceId: "2:2:0",
30
+ payload: { type: "text", text: "Second answer." },
31
+ },
32
+ {
33
+ type: "turn/end",
34
+ seq: 3,
35
+ timestamp: "2026-09-04T01:02:05.000Z",
36
+ turn: 2,
37
+ outcome: "completed",
38
+ },
39
+ ] as SessionEvent[],
40
+ });
41
+ expect(delivery).toMatchObject({
42
+ outcome: "answered",
43
+ askId: "ask-1",
44
+ answer: "First answer.",
45
+ });
46
+ });
47
+
48
+ test("keeps a delivery until the User ledger accepts it", async () => {
49
+ const values = new Map<string, unknown>();
50
+ const outbox = new VoiceAnswerOutboxV1({
51
+ get: (key) => Promise.resolve(values.get(key) as never),
52
+ put: (key, value) => {
53
+ values.set(key, value);
54
+ return Promise.resolve();
55
+ },
56
+ delete: (key) => Promise.resolve(values.delete(key)),
57
+ });
58
+ const delivery = {
59
+ schemaVersion: 1 as const,
60
+ outcome: "answered" as const,
61
+ userId: "user-1",
62
+ askId: "ask-1",
63
+ botId: "research",
64
+ runId: "agent-1",
65
+ answer: "Done.",
66
+ at: "2026-09-04T01:02:03.000Z",
67
+ };
68
+ await outbox.append(delivery);
69
+ await expect(
70
+ outbox.drain({
71
+ recordVoiceAnswer: () => Promise.reject(new Error("away")),
72
+ }),
73
+ ).rejects.toThrow("away");
74
+ expect(await outbox.state()).toMatchObject({ pending: 1 });
75
+ await outbox.drain({ recordVoiceAnswer: () => Promise.resolve() });
76
+ expect(await outbox.state()).toMatchObject({ pending: 0 });
77
+ });
78
+ });