@kuralle-syrinx/cf-agents 3.0.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kuralle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # @kuralle-syrinx/cf-agents
2
+
3
+ `withVoice(Agent, options)` — add a Syrinx voice pipeline to a Cloudflare
4
+ [`agents`](https://github.com/cloudflare/agents) SDK `Agent`. Supports both a
5
+ **realtime** front (Gemini Live / OpenAI Realtime) and a **cascaded**
6
+ STT → reasoner → TTS pipeline.
7
+
8
+ It is a mixin **over** the `Agent`, not a raw Durable Object: it reuses the
9
+ Agent's native hibernation, `keepAlive()` lease, `Connection`, and SQL, and hands
10
+ each connection to Syrinx's published edge runner
11
+ (`runVoiceEdgeWebSocketConnection`) wrapped as a `ManagedSocket`. When the Agent
12
+ exposes a public kuralle `runtime`, it is the brain by default
13
+ (`fromKuralleRuntime(this.runtime)`); otherwise pass `reasoner` explicitly.
14
+
15
+ `agents` is a **peer dependency** — install it alongside this package.
16
+
17
+ ## Realtime
18
+
19
+ ```ts
20
+ import { Agent, routeAgentRequest } from "agents";
21
+ import { withVoice } from "@kuralle-syrinx/cf-agents";
22
+ import { fromGeminiLive } from "@kuralle-syrinx/realtime";
23
+
24
+ export class SupportVoiceAgent extends withVoice<Env, typeof Agent<Env>>(Agent<Env>, {
25
+ pipeline: {
26
+ kind: "realtime",
27
+ front: (env) => fromGeminiLive({ apiKey: env.GEMINI_API_KEY, tools: [CONSULT] }),
28
+ delegateToolName: "consult_knowledge",
29
+ },
30
+ // reasoner defaults to fromKuralleRuntime(this.runtime, { sessionId })
31
+ }) {}
32
+
33
+ export default {
34
+ fetch: (request: Request, env: Env) =>
35
+ routeAgentRequest(request, env).then((r) => r ?? new Response("Not found", { status: 404 })),
36
+ };
37
+ ```
38
+
39
+ ## Cascaded
40
+
41
+ ```ts
42
+ import { withVoice } from "@kuralle-syrinx/cf-agents";
43
+ import { DeepgramSTTPlugin } from "@kuralle-syrinx/deepgram";
44
+ import { CartesiaTTSPlugin } from "@kuralle-syrinx/cartesia";
45
+ import { createWorkersSocket } from "@kuralle-syrinx/ws/workers";
46
+
47
+ export class SupportVoiceAgent extends withVoice<Env, typeof Agent<Env>>(Agent<Env>, {
48
+ pipeline: {
49
+ kind: "cascaded",
50
+ stt: (env) => ({
51
+ plugin: new DeepgramSTTPlugin(createWorkersSocket),
52
+ config: { api_key: env.DEEPGRAM_API_KEY, model: "nova-3", sample_rate: 16000 },
53
+ }),
54
+ tts: (env) => ({
55
+ plugin: new CartesiaTTSPlugin(createWorkersSocket),
56
+ config: { api_key: env.CARTESIA_API_KEY, voice_id: env.CARTESIA_VOICE_ID, model_id: "sonic-3" },
57
+ }),
58
+ // optional: vad, eos (set endpointingOwner: "smart_turn" when supplying eos)
59
+ },
60
+ // reasoner defaults to fromKuralleRuntime(this.runtime); required for non-kuralle agents
61
+ }) {}
62
+ ```
63
+
64
+ ## Options
65
+
66
+ | Option | Description |
67
+ | --- | --- |
68
+ | `pipeline` | `{ kind: "realtime", front, delegateToolName? }` or `{ kind: "cascaded", stt, tts, vad?, eos?, endpointingOwner? }`. |
69
+ | `reasoner` | `(env, { sessionId }) => Reasoner`. Defaults to `fromKuralleRuntime(this.runtime)` when the Agent exposes a kuralle `runtime`. Required for cascaded agents without one. |
70
+ | `recorder` | `(env, { sessionId }) => EdgeRecorder \| undefined` — optional per-call recorder (e.g. R2). |
71
+ | `inputSampleRateHz` / `outputSampleRateHz` | Edge audio rates (default 16000). |
72
+ | `resumeWindowMs` | How long a dropped connection can resume its session. |
73
+ | `sessionId` | `(request, agentName) => string`. Defaults to the `?sessionId=` query param (so a reconnecting client can resume), else a per-connection random id. (Not the Agent name — concurrent connections to one instance must not share a session.) |
74
+
75
+ The client speaks Syrinx's edge voice protocol — connect a
76
+ `@kuralle-syrinx/browser-client` to
77
+ `wss://<worker>/agents/<agent-class-kebab>/<instance>`.
78
+
79
+ See [`examples/03-cf-agent-voice`](../../examples/03-cf-agent-voice) for a runnable worker.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@kuralle-syrinx/cf-agents",
3
+ "version": "3.0.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "description": "withVoice(Agent) — add a Syrinx realtime or cascaded voice pipeline to a Cloudflare agents SDK Agent",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./r2-recorder": "./src/r2-recorder.ts"
13
+ },
14
+ "dependencies": {
15
+ "@kuralle-syrinx/core": "3.0.0",
16
+ "@kuralle-syrinx/realtime": "3.0.0",
17
+ "@kuralle-syrinx/aisdk": "3.0.0",
18
+ "@kuralle-syrinx/server-websocket": "3.0.0",
19
+ "@kuralle-syrinx/ws": "3.0.0",
20
+ "@kuralle-syrinx/recorder": "3.0.0",
21
+ "@kuralle-syrinx/kuralle": "3.0.0"
22
+ },
23
+ "peerDependencies": {
24
+ "agents": ">=0.14.0 <1.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "@cloudflare/workers-types": "^4.20260601.0",
28
+ "agents": "0.14.0",
29
+ "typescript": "^5.7.0",
30
+ "vitest": "^2.1.0"
31
+ },
32
+ "keywords": [
33
+ "kuralle",
34
+ "syrinx",
35
+ "voice",
36
+ "cloudflare",
37
+ "agents",
38
+ "durable-objects",
39
+ "realtime",
40
+ "cascaded",
41
+ "stt",
42
+ "tts"
43
+ ],
44
+ "scripts": {
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "vitest run"
47
+ }
48
+ }
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, it, expect } from "vitest";
4
+ import { VoiceAgentSession, type Reasoner, type VoicePlugin } from "@kuralle-syrinx/core";
5
+ import type { RealtimeAdapter } from "@kuralle-syrinx/realtime";
6
+ import { buildVoiceSession, type VoicePipeline } from "./build-session.js";
7
+
8
+ const stubPlugin = (): VoicePlugin => ({
9
+ initialize: async () => {},
10
+ close: async () => {},
11
+ });
12
+
13
+ const stubReasoner = (): Reasoner => ({
14
+ // eslint-disable-next-line require-yield
15
+ stream: async function* () {
16
+ return;
17
+ },
18
+ });
19
+
20
+ const stubFront = (): RealtimeAdapter => ({}) as unknown as RealtimeAdapter;
21
+
22
+ const ctx = { sessionId: "s1" };
23
+
24
+ describe("buildVoiceSession", () => {
25
+ it("builds a realtime session from a realtime pipeline", () => {
26
+ const pipeline: VoicePipeline<unknown> = {
27
+ kind: "realtime",
28
+ front: () => stubFront(),
29
+ delegateToolName: "consult_knowledge",
30
+ };
31
+ const session = buildVoiceSession(pipeline, {}, stubReasoner(), ctx);
32
+ expect(session).toBeInstanceOf(VoiceAgentSession);
33
+ });
34
+
35
+ it("allows a realtime session with no reasoner (front-only)", () => {
36
+ const pipeline: VoicePipeline<unknown> = { kind: "realtime", front: () => stubFront() };
37
+ const session = buildVoiceSession(pipeline, {}, undefined, ctx);
38
+ expect(session).toBeInstanceOf(VoiceAgentSession);
39
+ });
40
+
41
+ it("builds a cascaded session from a cascaded pipeline", () => {
42
+ const pipeline: VoicePipeline<unknown> = {
43
+ kind: "cascaded",
44
+ stt: () => ({ plugin: stubPlugin(), config: { model: "nova-3" } }),
45
+ tts: () => ({ plugin: stubPlugin(), config: { voice_id: "v" } }),
46
+ };
47
+ const session = buildVoiceSession(pipeline, {}, stubReasoner(), ctx);
48
+ expect(session).toBeInstanceOf(VoiceAgentSession);
49
+ });
50
+
51
+ it("carries sttForceFinalizeTimeoutMs through to the cascaded session", () => {
52
+ // Provider-endpointed cascades (e.g. Deepgram) tune this below the engine default; the mixin
53
+ // must thread it through instead of silently reverting to 7000ms.
54
+ const pipeline: VoicePipeline<unknown> = {
55
+ kind: "cascaded",
56
+ stt: () => ({ plugin: stubPlugin(), config: { model: "nova-3" } }),
57
+ tts: () => ({ plugin: stubPlugin(), config: { voice_id: "v" } }),
58
+ endpointingOwner: "provider_stt",
59
+ sttForceFinalizeTimeoutMs: 3500,
60
+ };
61
+ const session = buildVoiceSession(pipeline, {}, stubReasoner(), ctx);
62
+ expect(session).toBeInstanceOf(VoiceAgentSession);
63
+ });
64
+
65
+ it("throws a clear error when a cascaded pipeline has no reasoner", () => {
66
+ const pipeline: VoicePipeline<unknown> = {
67
+ kind: "cascaded",
68
+ stt: () => ({ plugin: stubPlugin() }),
69
+ tts: () => ({ plugin: stubPlugin() }),
70
+ };
71
+ expect(() => buildVoiceSession(pipeline, {}, undefined, ctx)).toThrow(/cascaded pipeline needs a reasoner/);
72
+ });
73
+
74
+ it('throws when endpointingOwner is "smart_turn" but no eos stage is provided', () => {
75
+ const pipeline: VoicePipeline<unknown> = {
76
+ kind: "cascaded",
77
+ stt: () => ({ plugin: stubPlugin() }),
78
+ tts: () => ({ plugin: stubPlugin() }),
79
+ endpointingOwner: "smart_turn",
80
+ };
81
+ expect(() => buildVoiceSession(pipeline, {}, stubReasoner(), ctx)).toThrow(/smart_turn/);
82
+ });
83
+ });
@@ -0,0 +1,126 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import {
4
+ VoiceAgentSession,
5
+ type Reasoner,
6
+ type VoicePlugin,
7
+ type PluginConfig,
8
+ } from "@kuralle-syrinx/core";
9
+ import { RealtimeBridge, type RealtimeAdapter } from "@kuralle-syrinx/realtime";
10
+ import { ReasoningBridge } from "@kuralle-syrinx/aisdk";
11
+
12
+ /** Per-session context handed to every pipeline factory. */
13
+ export interface VoicePipelineContext {
14
+ readonly sessionId: string;
15
+ }
16
+
17
+ /**
18
+ * Realtime front pipeline: a single speech-to-speech model (Gemini Live,
19
+ * OpenAI Realtime, …) owns STT+TTS+turn-taking; the reasoner is consulted via a
20
+ * front-model delegate tool. Assembled exactly as the verified realtime path:
21
+ * `plugins: { realtime: {} }`, `endpointingOwner: "timer"`.
22
+ */
23
+ export interface RealtimePipeline<Env> {
24
+ readonly kind: "realtime";
25
+ /** The realtime front adapter, e.g. `fromGeminiLive(...)` / `fromOpenAIRealtime(...)`. */
26
+ readonly front: (env: Env, ctx: VoicePipelineContext) => RealtimeAdapter;
27
+ /** Name of the front-model tool routed to the reasoner. @default "consult_knowledge" */
28
+ readonly delegateToolName?: string;
29
+ }
30
+
31
+ /** A cascaded-stage plugin plus its `VoiceAgentSession` plugin config. */
32
+ export interface CascadedStage {
33
+ readonly plugin: VoicePlugin;
34
+ readonly config?: PluginConfig;
35
+ }
36
+
37
+ /**
38
+ * Cascaded pipeline: discrete STT → reasoner → TTS stages (optionally VAD + a
39
+ * Smart-Turn EOS). Assembled exactly as the verified cascaded path:
40
+ * `plugins: { stt, vad?, eos?, bridge, tts }`, reasoner wrapped in a
41
+ * `ReasoningBridge` registered as `"bridge"`.
42
+ */
43
+ export interface CascadedPipeline<Env> {
44
+ readonly kind: "cascaded";
45
+ readonly stt: (env: Env, ctx: VoicePipelineContext) => CascadedStage;
46
+ readonly tts: (env: Env, ctx: VoicePipelineContext) => CascadedStage;
47
+ readonly vad?: (env: Env, ctx: VoicePipelineContext) => CascadedStage;
48
+ readonly eos?: (env: Env, ctx: VoicePipelineContext) => CascadedStage;
49
+ /**
50
+ * Which component owns end-of-speech. @default "provider_stt". Set to
51
+ * "smart_turn" when supplying an `eos` stage.
52
+ */
53
+ readonly endpointingOwner?: "provider_stt" | "smart_turn";
54
+ /**
55
+ * Fallback timeout (ms) before the engine force-finalizes a turn when the STT provider's own
56
+ * endpointing/finalize never fires. Maps to `VoiceAgentSession`'s `sttForceFinalizeTimeoutMs`
57
+ * (engine default 7000). Set it when a provider-endpointed cascade tunes this (e.g. Deepgram at 3500).
58
+ */
59
+ readonly sttForceFinalizeTimeoutMs?: number;
60
+ }
61
+
62
+ export type VoicePipeline<Env> = RealtimePipeline<Env> | CascadedPipeline<Env>;
63
+
64
+ /**
65
+ * Assemble a `VoiceAgentSession` for the configured pipeline. This is the single
66
+ * place that maps the high-level pipeline config onto Syrinx's plugin slots, so
67
+ * the realtime and cascaded shapes stay first-class instead of mode-flagged.
68
+ */
69
+ export function buildVoiceSession<Env>(
70
+ pipeline: VoicePipeline<Env>,
71
+ env: Env,
72
+ reasoner: Reasoner | undefined,
73
+ ctx: VoicePipelineContext,
74
+ ): VoiceAgentSession {
75
+ if (pipeline.kind === "realtime") {
76
+ const front = pipeline.front(env, ctx);
77
+ const bridge = new RealtimeBridge(front, reasoner, pipeline.delegateToolName);
78
+ const session = new VoiceAgentSession({
79
+ plugins: { realtime: {} },
80
+ endpointingOwner: "timer",
81
+ });
82
+ session.registerPlugin("realtime", bridge);
83
+ return session;
84
+ }
85
+
86
+ if (!reasoner) {
87
+ throw new Error(
88
+ "withVoice: a cascaded pipeline needs a reasoner. Set `reasoner` in the options, " +
89
+ "or expose a kuralle `runtime` on the Agent so it defaults to fromKuralleRuntime(this.runtime).",
90
+ );
91
+ }
92
+
93
+ const stt = pipeline.stt(env, ctx);
94
+ const tts = pipeline.tts(env, ctx);
95
+ const vad = pipeline.vad?.(env, ctx);
96
+ const eos = pipeline.eos?.(env, ctx);
97
+
98
+ if (pipeline.endpointingOwner === "smart_turn" && !eos) {
99
+ throw new Error(
100
+ 'withVoice: a cascaded pipeline with endpointingOwner "smart_turn" must provide an `eos` stage ' +
101
+ "(e.g. a PipecatEOSPlugin); otherwise no component owns end-of-speech and turns never complete.",
102
+ );
103
+ }
104
+
105
+ const plugins: Record<string, PluginConfig> = {
106
+ stt: stt.config ?? {},
107
+ bridge: {},
108
+ tts: tts.config ?? {},
109
+ };
110
+ if (vad) plugins["vad"] = vad.config ?? {};
111
+ if (eos) plugins["eos"] = eos.config ?? {};
112
+
113
+ const session = new VoiceAgentSession({
114
+ plugins,
115
+ endpointingOwner: pipeline.endpointingOwner ?? "provider_stt",
116
+ ...(pipeline.sttForceFinalizeTimeoutMs !== undefined
117
+ ? { sttForceFinalizeTimeoutMs: pipeline.sttForceFinalizeTimeoutMs }
118
+ : {}),
119
+ });
120
+ session.registerPlugin("stt", stt.plugin);
121
+ session.registerPlugin("bridge", new ReasoningBridge(reasoner));
122
+ session.registerPlugin("tts", tts.plugin);
123
+ if (vad) session.registerPlugin("vad", vad.plugin);
124
+ if (eos) session.registerPlugin("eos", eos.plugin);
125
+ return session;
126
+ }
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, it, expect, vi } from "vitest";
4
+ import { connectionManagedSocket, type VoiceConnection } from "./connection-socket.js";
5
+
6
+ function fakeConnection(readyState = 1): VoiceConnection & { sent: Array<string | ArrayBuffer | ArrayBufferView>; closed: boolean } {
7
+ const sent: Array<string | ArrayBuffer | ArrayBufferView> = [];
8
+ let closed = false;
9
+ return {
10
+ id: "c1",
11
+ get readyState() {
12
+ return closed ? 3 : readyState;
13
+ },
14
+ send(data) {
15
+ sent.push(data);
16
+ },
17
+ close() {
18
+ closed = true;
19
+ },
20
+ sent,
21
+ get closed() {
22
+ return closed;
23
+ },
24
+ };
25
+ }
26
+
27
+ describe("connectionManagedSocket", () => {
28
+ it("forwards send() to the connection and reflects readyState via isOpen", () => {
29
+ const conn = fakeConnection();
30
+ const { socket } = connectionManagedSocket(conn);
31
+ expect(socket.isOpen).toBe(true);
32
+ socket.send("hello");
33
+ socket.send(new Uint8Array([1, 2, 3]));
34
+ expect(conn.sent).toEqual(["hello", new Uint8Array([1, 2, 3])]);
35
+ });
36
+
37
+ it("isOpen is false when the connection is not OPEN", () => {
38
+ const conn = fakeConnection(0);
39
+ const { socket } = connectionManagedSocket(conn);
40
+ expect(socket.isOpen).toBe(false);
41
+ });
42
+
43
+ it("dispose() closes the underlying connection", () => {
44
+ const conn = fakeConnection();
45
+ const { socket } = connectionManagedSocket(conn);
46
+ socket.dispose();
47
+ expect(conn.closed).toBe(true);
48
+ expect(socket.isOpen).toBe(false);
49
+ });
50
+
51
+ it("controller.message pumps string frames as text and ArrayBuffer as binary", () => {
52
+ const { socket, controller } = connectionManagedSocket(fakeConnection());
53
+ const received: Array<{ data: unknown; isBinary: boolean }> = [];
54
+ socket.onMessage((data, isBinary) => received.push({ data, isBinary }));
55
+
56
+ controller.message("a-json-frame");
57
+ const buf = new Uint8Array([9, 8, 7]).buffer;
58
+ controller.message(buf);
59
+
60
+ expect(received).toHaveLength(2);
61
+ expect(received[0]).toEqual({ data: "a-json-frame", isBinary: false });
62
+ expect(received[1]?.isBinary).toBe(true);
63
+ expect(received[1]?.data).toBeInstanceOf(Uint8Array);
64
+ expect(Array.from(received[1]?.data as Uint8Array)).toEqual([9, 8, 7]);
65
+ });
66
+
67
+ it("controller.close and controller.error fan out to registered handlers", () => {
68
+ const { socket, controller } = connectionManagedSocket(fakeConnection());
69
+ const onClose = vi.fn();
70
+ const onError = vi.fn();
71
+ socket.onClose(onClose);
72
+ socket.onError(onError);
73
+
74
+ controller.close(1011, "boom");
75
+ controller.error(new Error("network"));
76
+
77
+ expect(onClose).toHaveBeenCalledWith(1011, "boom");
78
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: "network" }));
79
+ });
80
+
81
+ it("dispose() fires the close handlers (so edge teardown runs) and closes the connection", () => {
82
+ const conn = fakeConnection();
83
+ const { socket } = connectionManagedSocket(conn);
84
+ const onClose = vi.fn();
85
+ socket.onClose(onClose);
86
+
87
+ socket.dispose();
88
+
89
+ expect(onClose).toHaveBeenCalledTimes(1);
90
+ expect(conn.closed).toBe(true);
91
+ expect(socket.isOpen).toBe(false);
92
+ });
93
+
94
+ it("fires close at most once across dispose() and controller.close()", () => {
95
+ const { socket, controller } = connectionManagedSocket(fakeConnection());
96
+ const onClose = vi.fn();
97
+ socket.onClose(onClose);
98
+
99
+ socket.dispose();
100
+ controller.close(1000, "later");
101
+ controller.close(1000, "again");
102
+
103
+ expect(onClose).toHaveBeenCalledTimes(1);
104
+ });
105
+
106
+ it("onOpen fires on the next microtask when already open", async () => {
107
+ const { socket } = connectionManagedSocket(fakeConnection());
108
+ const onOpen = vi.fn();
109
+ socket.onOpen(onOpen);
110
+ expect(onOpen).not.toHaveBeenCalled();
111
+ await Promise.resolve();
112
+ expect(onOpen).toHaveBeenCalledOnce();
113
+ });
114
+ });
@@ -0,0 +1,113 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { ManagedSocket, SocketData } from "@kuralle-syrinx/ws";
4
+
5
+ /**
6
+ * The subset of the `agents` SDK `Connection` (itself a WebSocket) that the
7
+ * voice bridge drives. Kept structural so the package never has to import the
8
+ * full `Connection` type at runtime — the mixin passes the real Connection in.
9
+ */
10
+ export interface VoiceConnection {
11
+ readonly id: string;
12
+ readonly readyState: number;
13
+ send(data: string | ArrayBuffer | ArrayBufferView): void;
14
+ close(code?: number, reason?: string): void;
15
+ }
16
+
17
+ /**
18
+ * Pumps the Agent's per-connection lifecycle callbacks into the `ManagedSocket`.
19
+ * The mixin feeds this from its patched `onMessage` / `onClose` hooks.
20
+ */
21
+ export interface ConnectionSocketController {
22
+ message(data: string | ArrayBuffer): void;
23
+ close(code: number, reason: string): void;
24
+ error(err?: Error): void;
25
+ }
26
+
27
+ const WEBSOCKET_OPEN = 1;
28
+
29
+ /**
30
+ * Wrap an `agents` `Connection` as a Syrinx `ManagedSocket`.
31
+ *
32
+ * The Agent base delivers inbound frames through its `onMessage(connection, …)`
33
+ * hook — which keeps working across Durable Object hibernation, unlike attaching
34
+ * raw `addEventListener` handlers to the socket (the trap that forces
35
+ * `static options = { hibernate: false }` in the OpenAI/Twilio examples). So this
36
+ * socket is *externally pumped*: the mixin forwards lifecycle events to the
37
+ * returned controller, and the controller fans them out to the handlers
38
+ * `runVoiceEdgeWebSocketConnection` registers.
39
+ *
40
+ * Mirrors the DO-owned controlled socket in `@kuralle-syrinx/ws/workers`, but the
41
+ * Agent has already accepted the connection, so there is no `WebSocketPair` to
42
+ * create or `accept()` to call here.
43
+ */
44
+ export function connectionManagedSocket(connection: VoiceConnection): {
45
+ readonly socket: ManagedSocket;
46
+ readonly controller: ConnectionSocketController;
47
+ } {
48
+ const messageHandlers = new Set<(data: SocketData, isBinary: boolean) => void>();
49
+ const closeHandlers = new Set<(code: number, reason: string) => void>();
50
+ const errorHandlers = new Set<(err: Error) => void>();
51
+ let closed = false;
52
+
53
+ // Fire the close handlers exactly once, whether the close was initiated by the
54
+ // client (pumped via controller.close from the Agent's onClose hook) or by the
55
+ // edge runner / mixin calling socket.dispose(). The edge runner registers its
56
+ // teardown (recorder finalize, session-lease release) as a close handler, so it
57
+ // must run on a dispose() too — not only when the platform happens to deliver a
58
+ // server-initiated onClose. Iterate a copy so a handler may deregister safely.
59
+ const fireClose = (code: number, reason: string): void => {
60
+ if (closed) return;
61
+ closed = true;
62
+ for (const handler of [...closeHandlers]) handler(code, reason);
63
+ };
64
+
65
+ return {
66
+ socket: {
67
+ get isOpen(): boolean {
68
+ return !closed && connection.readyState === WEBSOCKET_OPEN;
69
+ },
70
+ send: (data) => connection.send(data),
71
+ keepAlivePing: () => undefined,
72
+ verify: async () => !closed && connection.readyState === WEBSOCKET_OPEN,
73
+ dispose: () => {
74
+ fireClose(1006, "disposed");
75
+ try {
76
+ connection.close();
77
+ } catch {
78
+ /* already closing */
79
+ }
80
+ },
81
+ onOpen: (handler) => {
82
+ // The connection is already open by the time the mixin wraps it.
83
+ if (connection.readyState === WEBSOCKET_OPEN) queueMicrotask(handler);
84
+ },
85
+ onMessage: (handler) => {
86
+ messageHandlers.add(handler);
87
+ },
88
+ onClose: (handler) => {
89
+ closeHandlers.add(handler);
90
+ },
91
+ onError: (handler) => {
92
+ errorHandlers.add(handler);
93
+ },
94
+ },
95
+ controller: {
96
+ message(data) {
97
+ if (typeof data === "string") {
98
+ for (const handler of [...messageHandlers]) handler(data, false);
99
+ return;
100
+ }
101
+ const bytes = new Uint8Array(data);
102
+ for (const handler of [...messageHandlers]) handler(bytes, true);
103
+ },
104
+ close(code, reason) {
105
+ fireClose(code, reason);
106
+ },
107
+ error(err) {
108
+ const error = err ?? new Error("WebSocket error");
109
+ for (const handler of [...errorHandlers]) handler(error);
110
+ },
111
+ },
112
+ };
113
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // @kuralle-syrinx/cf-agents — add a Syrinx voice pipeline (realtime or cascaded) to
4
+ // a Cloudflare `agents` SDK Agent via the `withVoice(Agent, options)` mixin.
5
+
6
+ export { withVoice, type WithVoiceOptions, type WithVoiceMembers } from "./with-voice.js";
7
+ export type {
8
+ VoicePipeline,
9
+ RealtimePipeline,
10
+ CascadedPipeline,
11
+ CascadedStage,
12
+ VoicePipelineContext,
13
+ } from "./build-session.js";
14
+ export { connectionManagedSocket } from "./connection-socket.js";
15
+ export type { VoiceConnection, ConnectionSocketController } from "./connection-socket.js";