@kuralle-syrinx/server-workers 4.4.0 → 4.5.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/dist/kuralle-realtime-agent.d.ts +16 -0
- package/dist/kuralle-realtime-agent.d.ts.map +1 -0
- package/dist/kuralle-realtime-agent.js +156 -0
- package/dist/kuralle-realtime-agent.js.map +1 -0
- package/dist/live-realtime-session.d.ts +19 -0
- package/dist/live-realtime-session.d.ts.map +1 -0
- package/dist/live-realtime-session.js +65 -0
- package/dist/live-realtime-session.js.map +1 -0
- package/dist/live-session.d.ts +27 -0
- package/dist/live-session.d.ts.map +1 -0
- package/dist/live-session.js +93 -0
- package/dist/live-session.js.map +1 -0
- package/dist/worker-realtime.d.ts +13 -0
- package/dist/worker-realtime.d.ts.map +1 -0
- package/dist/worker-realtime.js +31 -0
- package/dist/worker-realtime.js.map +1 -0
- package/dist/worker.d.ts +32 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +172 -0
- package/dist/worker.js.map +1 -0
- package/dist/workers-ai-smart-turn.d.ts +24 -0
- package/dist/workers-ai-smart-turn.d.ts.map +1 -0
- package/dist/workers-ai-smart-turn.js +36 -0
- package/dist/workers-ai-smart-turn.js.map +1 -0
- package/package.json +22 -15
- package/src/live-realtime-session.test.ts +88 -0
- package/src/live-session.test.ts +129 -0
- package/src/worker-runtime.test.ts +480 -0
- package/src/workers-ai-smart-turn.test.ts +67 -0
- package/.dev.vars.example +0 -17
- package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
- package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
- package/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/642a4be3d8c37b7e8567dadb740c8b4480a517ece8a057e38786f06353895683.sqlite +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/642a4be3d8c37b7e8567dadb740c8b4480a517ece8a057e38786f06353895683.sqlite-shm +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/642a4be3d8c37b7e8567dadb740c8b4480a517ece8a057e38786f06353895683.sqlite-wal +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/metadata.sqlite +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/metadata.sqlite-shm +0 -0
- package/.wrangler/state/v3/r2/miniflare-R2BucketObject/metadata.sqlite-wal +0 -0
- package/wrangler.jsonc +0 -49
- package/wrangler.realtime-gemini.jsonc +0 -28
- package/wrangler.realtime.jsonc +0 -27
- package/wrangler.smartturn-spike.jsonc +0 -44
package/dist/worker.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Cloudflare Workers cascaded voice host, built on `withVoice(Agent)` (issue #10 / W1).
|
|
4
|
+
// The Durable Object IS an `agents` SDK Agent: it provides hibernation, the
|
|
5
|
+
// `keepAlive()` lease, `Connection` lifecycle, and SQLite natively — so the prior
|
|
6
|
+
// manual alarm scheduler, durable session store, WebSocket lifecycle, and the 1012
|
|
7
|
+
// eviction-orphan workaround are gone. The brain is the `reasoner` param; the
|
|
8
|
+
// pipeline (Deepgram STT/TTS + kuralle) lives in live-session.ts.
|
|
9
|
+
import { Agent } from "agents";
|
|
10
|
+
import { withVoice } from "@kuralle-syrinx/cf-agents";
|
|
11
|
+
import { R2EdgeRecorder } from "@kuralle-syrinx/cf-agents/r2-recorder";
|
|
12
|
+
import { createLiveReasoner, liveCascadedPipeline, } from "./live-session.js";
|
|
13
|
+
const INPUT_SAMPLE_RATE_HZ = 16000;
|
|
14
|
+
const OUTPUT_SAMPLE_RATE_HZ = 16000;
|
|
15
|
+
/** Browser/edge cascaded host (Syrinx JSON+envelope protocol over /ws). */
|
|
16
|
+
export class VoiceConversation extends withVoice((Agent), {
|
|
17
|
+
pipeline: liveCascadedPipeline,
|
|
18
|
+
reasoner: (env, ctx) => createLiveReasoner(env, ctx),
|
|
19
|
+
recorder: (env, { sessionId }) => env.RECORDINGS
|
|
20
|
+
? new R2EdgeRecorder({ bucket: env.RECORDINGS, sessionId, startedAtMs: Date.now() })
|
|
21
|
+
: undefined,
|
|
22
|
+
inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
23
|
+
outputSampleRateHz: OUTPUT_SAMPLE_RATE_HZ,
|
|
24
|
+
resumeWindowMs: 15_000,
|
|
25
|
+
// Browser/studio clients: no telephony-style idle re-engagement. The 15s default nags
|
|
26
|
+
// ("Are you still there?") and disconnects a user who is granting mic or reading the UI
|
|
27
|
+
// before speaking; the DO hibernates abandoned browser sessions anyway. Telephony keeps
|
|
28
|
+
// the default on TwilioVoiceConversation below.
|
|
29
|
+
idleTimeout: { durationMs: 0 },
|
|
30
|
+
}) {
|
|
31
|
+
}
|
|
32
|
+
/** Telephony cascaded host (Twilio Media Streams μ-law 8 kHz over /twilio). Same pipeline/brain. */
|
|
33
|
+
export class TwilioVoiceConversation extends withVoice((Agent), {
|
|
34
|
+
transport: "twilio",
|
|
35
|
+
pipeline: liveCascadedPipeline,
|
|
36
|
+
reasoner: (env, ctx) => createLiveReasoner(env, ctx),
|
|
37
|
+
inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
38
|
+
resumeWindowMs: 15_000,
|
|
39
|
+
}) {
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Telephony cascaded host (Telnyx Media Streaming over /telnyx). Same pipeline/brain;
|
|
43
|
+
* idle timeout keeps the telephony re-engagement default. Live streaming_start →
|
|
44
|
+
* /telnyx is carrier-gated / unit-tested only.
|
|
45
|
+
*/
|
|
46
|
+
export class TelnyxVoiceConversation extends withVoice((Agent), {
|
|
47
|
+
transport: "telnyx",
|
|
48
|
+
pipeline: liveCascadedPipeline,
|
|
49
|
+
reasoner: (env, ctx) => createLiveReasoner(env, ctx),
|
|
50
|
+
inputSampleRateHz: INPUT_SAMPLE_RATE_HZ,
|
|
51
|
+
resumeWindowMs: 15_000,
|
|
52
|
+
}) {
|
|
53
|
+
}
|
|
54
|
+
export default {
|
|
55
|
+
async fetch(request, env) {
|
|
56
|
+
const url = new URL(request.url);
|
|
57
|
+
if (url.pathname === "/health")
|
|
58
|
+
return new Response("ok");
|
|
59
|
+
if (url.pathname === "/recordings")
|
|
60
|
+
return await listRecordings(url, env);
|
|
61
|
+
// Twilio Voice webhook: returns TwiML that bridges the inbound PSTN call to this
|
|
62
|
+
// worker's own /twilio Media Streams endpoint over a bidirectional <Stream>. Point a
|
|
63
|
+
// Twilio number's "A call comes in" webhook at https://<host>/incoming-call.
|
|
64
|
+
if (url.pathname === "/incoming-call")
|
|
65
|
+
return await twilioIncomingCallResponse(request, url);
|
|
66
|
+
// Telnyx TeXML / Call Control stream-start helper: pure payload construction.
|
|
67
|
+
// Live dispatch of streaming_start to a real trunk is carrier-gated.
|
|
68
|
+
if (url.pathname === "/telnyx-stream-start")
|
|
69
|
+
return await telnyxStreamStartResponse(request, url);
|
|
70
|
+
// Name-addressed routing: one DO per sessionId (callSid for telephony). The Agent
|
|
71
|
+
// (partyserver) resolves its name from ctx.id.name, so a direct stub.fetch() upgrade
|
|
72
|
+
// is valid for both transports.
|
|
73
|
+
const sessionId = url.searchParams.get("sessionId") ?? crypto.randomUUID();
|
|
74
|
+
if (url.pathname === "/twilio") {
|
|
75
|
+
const id = env.TWILIO_VOICE_CONVERSATIONS.idFromName(sessionId);
|
|
76
|
+
return await env.TWILIO_VOICE_CONVERSATIONS.get(id).fetch(request);
|
|
77
|
+
}
|
|
78
|
+
if (url.pathname === "/telnyx") {
|
|
79
|
+
const id = env.TELNYX_VOICE_CONVERSATIONS.idFromName(sessionId);
|
|
80
|
+
return await env.TELNYX_VOICE_CONVERSATIONS.get(id).fetch(request);
|
|
81
|
+
}
|
|
82
|
+
if (url.pathname !== "/ws")
|
|
83
|
+
return new Response("not found", { status: 404 });
|
|
84
|
+
const id = env.VOICE_CONVERSATIONS.idFromName(sessionId);
|
|
85
|
+
return await env.VOICE_CONVERSATIONS.get(id).fetch(request);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Twilio Voice webhook → TwiML connecting the PSTN leg to /twilio over a bidirectional
|
|
90
|
+
* <Stream>. The Twilio CallSid becomes the session id, so the call's media stream and any
|
|
91
|
+
* resume/recording key off the same stable id.
|
|
92
|
+
*/
|
|
93
|
+
async function twilioIncomingCallResponse(request, url) {
|
|
94
|
+
let callSid = url.searchParams.get("CallSid") ?? "";
|
|
95
|
+
if (!callSid && request.method === "POST") {
|
|
96
|
+
const form = await request.formData().catch(() => null);
|
|
97
|
+
const value = form?.get("CallSid");
|
|
98
|
+
if (typeof value === "string")
|
|
99
|
+
callSid = value;
|
|
100
|
+
}
|
|
101
|
+
const sessionId = callSid || crypto.randomUUID();
|
|
102
|
+
const wsScheme = url.protocol === "https:" ? "wss" : "ws";
|
|
103
|
+
const streamUrl = `${wsScheme}://${url.host}/twilio?sessionId=${encodeURIComponent(sessionId)}`;
|
|
104
|
+
const twiml = [
|
|
105
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
106
|
+
"<Response>",
|
|
107
|
+
" <Connect>",
|
|
108
|
+
` <Stream url="${xmlEscape(streamUrl)}" />`,
|
|
109
|
+
" </Connect>",
|
|
110
|
+
"</Response>",
|
|
111
|
+
].join("\n");
|
|
112
|
+
return new Response(twiml, { headers: { "content-type": "text/xml; charset=utf-8" } });
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Telnyx stream-start helper — pure payload construction (no live carrier dispatch).
|
|
116
|
+
*
|
|
117
|
+
* - Default (`format=texml` or omitted): TeXML `<Stream url="wss://…/telnyx?sessionId=…">`
|
|
118
|
+
* for TeXML applications that start bidirectional media via the Stream verb.
|
|
119
|
+
* - `format=streaming_start`: JSON body matching Call Control `streaming_start`
|
|
120
|
+
* (`stream_url`, `stream_bidirectional_mode`, `stream_bidirectional_codec`, …).
|
|
121
|
+
* Live POST to `/v2/calls/{call_control_id}/actions/streaming_start` is carrier-gated.
|
|
122
|
+
*
|
|
123
|
+
* Query: `sessionId` (optional), `codec` (PCMU|PCMA|G722|L16, default PCMU),
|
|
124
|
+
* `sampleRate` (default from codec), `format` (texml|streaming_start).
|
|
125
|
+
*/
|
|
126
|
+
async function telnyxStreamStartResponse(request, url) {
|
|
127
|
+
let sessionId = url.searchParams.get("sessionId")?.trim() ?? "";
|
|
128
|
+
if (!sessionId && request.method === "POST") {
|
|
129
|
+
const form = await request.formData().catch(() => null);
|
|
130
|
+
const value = form?.get("sessionId") ?? form?.get("CallControlId") ?? form?.get("call_control_id");
|
|
131
|
+
if (typeof value === "string")
|
|
132
|
+
sessionId = value.trim();
|
|
133
|
+
}
|
|
134
|
+
if (!sessionId)
|
|
135
|
+
sessionId = crypto.randomUUID();
|
|
136
|
+
const codec = (url.searchParams.get("codec")?.trim().toUpperCase() || "PCMU");
|
|
137
|
+
const defaultRate = codec === "L16" || codec === "G722" ? "16000" : "8000";
|
|
138
|
+
const sampleRate = url.searchParams.get("sampleRate")?.trim() || defaultRate;
|
|
139
|
+
const format = url.searchParams.get("format")?.trim().toLowerCase() || "texml";
|
|
140
|
+
const wsScheme = url.protocol === "https:" ? "wss" : "ws";
|
|
141
|
+
const streamUrl = `${wsScheme}://${url.host}/telnyx?sessionId=${encodeURIComponent(sessionId)}`;
|
|
142
|
+
if (format === "streaming_start") {
|
|
143
|
+
// Call Control streaming_start body — pure; live dispatch carrier-gated.
|
|
144
|
+
return Response.json({
|
|
145
|
+
stream_url: streamUrl,
|
|
146
|
+
stream_track: "inbound_track",
|
|
147
|
+
stream_bidirectional_mode: "rtp",
|
|
148
|
+
stream_bidirectional_codec: codec,
|
|
149
|
+
stream_bidirectional_sampling_rate: Number(sampleRate),
|
|
150
|
+
send_silence_when_idle: true,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const texml = [
|
|
154
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
155
|
+
"<Response>",
|
|
156
|
+
` <Stream url="${xmlEscape(streamUrl)}" bidirectionalMode="rtp" bidirectionalCodec="${xmlEscape(codec)}" bidirectionalSamplingRate="${xmlEscape(sampleRate)}" />`,
|
|
157
|
+
"</Response>",
|
|
158
|
+
].join("\n");
|
|
159
|
+
return new Response(texml, { headers: { "content-type": "text/xml; charset=utf-8" } });
|
|
160
|
+
}
|
|
161
|
+
function xmlEscape(value) {
|
|
162
|
+
return value.replace(/[<>&"']/g, (c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c] ?? c);
|
|
163
|
+
}
|
|
164
|
+
/** List recorded objects for a session: GET /recordings?sessionId=<id>. */
|
|
165
|
+
async function listRecordings(url, env) {
|
|
166
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
167
|
+
if (!env.RECORDINGS || !sessionId)
|
|
168
|
+
return new Response("not found", { status: 404 });
|
|
169
|
+
const listed = await env.RECORDINGS.list({ prefix: `recordings/${sessionId}/` });
|
|
170
|
+
return Response.json(listed.objects.map((o) => ({ key: o.key, size: o.size })));
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=worker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worker.js","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SmartTurnPredictor } from "@kuralle-syrinx/pipecat-smart-turn/eos";
|
|
2
|
+
import type { PluginConfig } from "@kuralle-syrinx/core";
|
|
3
|
+
/** Minimal Cloudflare Workers AI binding surface for the Smart Turn v2 model. */
|
|
4
|
+
export interface Ai {
|
|
5
|
+
run(model: string, input: unknown): Promise<{
|
|
6
|
+
is_complete?: boolean;
|
|
7
|
+
probability?: number;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Cloudflare Workers AI-hosted Smart Turn predictor.
|
|
12
|
+
*
|
|
13
|
+
* Uses the hosted `@cf/pipecat-ai/smart-turn-v2` model (v3 is NOT available in the
|
|
14
|
+
* Workers AI catalog). No ONNX is loaded locally — the model runs in Cloudflare's
|
|
15
|
+
* inference cluster, so `initialize`/`close` are no-ops.
|
|
16
|
+
*/
|
|
17
|
+
export declare class WorkersAiSmartTurnPredictor implements SmartTurnPredictor {
|
|
18
|
+
private readonly ai;
|
|
19
|
+
constructor(ai: Ai);
|
|
20
|
+
initialize(_config: PluginConfig): Promise<void>;
|
|
21
|
+
predict(audio: Float32Array): Promise<number>;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=workers-ai-smart-turn.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workers-ai-smart-turn.d.ts","sourceRoot":"","sources":["../src/workers-ai-smart-turn.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wCAAwC,CAAC;AACjF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAEzD,iFAAiF;AACjF,MAAM,WAAW,EAAE;IACjB,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC;QAC1C,WAAW,CAAC,EAAE,OAAO,CAAC;QACtB,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC,CAAC;CACJ;AAID;;;;;;GAMG;AACH,qBAAa,2BAA4B,YAAW,kBAAkB;IACxD,OAAO,CAAC,QAAQ,CAAC,EAAE;gBAAF,EAAE,EAAE,EAAE;IAE7B,UAAU,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAIhD,OAAO,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC;IAM7C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
const MODEL_ID = "@cf/pipecat-ai/smart-turn-v2";
|
|
3
|
+
/**
|
|
4
|
+
* Cloudflare Workers AI-hosted Smart Turn predictor.
|
|
5
|
+
*
|
|
6
|
+
* Uses the hosted `@cf/pipecat-ai/smart-turn-v2` model (v3 is NOT available in the
|
|
7
|
+
* Workers AI catalog). No ONNX is loaded locally — the model runs in Cloudflare's
|
|
8
|
+
* inference cluster, so `initialize`/`close` are no-ops.
|
|
9
|
+
*/
|
|
10
|
+
export class WorkersAiSmartTurnPredictor {
|
|
11
|
+
ai;
|
|
12
|
+
constructor(ai) {
|
|
13
|
+
this.ai = ai;
|
|
14
|
+
}
|
|
15
|
+
async initialize(_config) {
|
|
16
|
+
// Hosted model — nothing to load.
|
|
17
|
+
}
|
|
18
|
+
async predict(audio) {
|
|
19
|
+
const base64 = float32ArrayToBase64(audio);
|
|
20
|
+
const result = await this.ai.run(MODEL_ID, { audio: base64, dtype: "float32" });
|
|
21
|
+
return typeof result.probability === "number" ? result.probability : 0;
|
|
22
|
+
}
|
|
23
|
+
async close() {
|
|
24
|
+
// Nothing to release.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function float32ArrayToBase64(arr) {
|
|
28
|
+
const bytes = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
|
|
29
|
+
const chunks = [];
|
|
30
|
+
const chunkSize = 0x8000; // 32 KB — safe for String.fromCharCode spread
|
|
31
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
32
|
+
chunks.push(String.fromCharCode(...bytes.subarray(i, i + chunkSize)));
|
|
33
|
+
}
|
|
34
|
+
return btoa(chunks.join(""));
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=workers-ai-smart-turn.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workers-ai-smart-turn.js","sourceRoot":"","sources":["../src/workers-ai-smart-turn.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAa/B,MAAM,QAAQ,GAAG,8BAA8B,CAAC;AAEhD;;;;;;GAMG;AACH,MAAM,OAAO,2BAA2B;IACT;IAA7B,YAA6B,EAAM;QAAN,OAAE,GAAF,EAAE,CAAI;IAAG,CAAC;IAEvC,KAAK,CAAC,UAAU,CAAC,OAAqB;QACpC,kCAAkC;IACpC,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,KAAmB;QAC/B,MAAM,MAAM,GAAG,oBAAoB,CAAC,KAAK,CAAC,CAAC;QAC3C,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;QAChF,OAAO,OAAO,MAAM,CAAC,WAAW,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC;IACzE,CAAC;IAED,KAAK,CAAC,KAAK;QACT,sBAAsB;IACxB,CAAC;CACF;AAED,SAAS,oBAAoB,CAAC,GAAiB;IAC7C,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,UAAU,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;IACzE,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,MAAM,SAAS,GAAG,MAAM,CAAC,CAAC,8CAA8C;IACxE,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,SAAS,EAAE,CAAC;QACjD,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,GAAG,KAAK,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;IACxE,CAAC;IACD,OAAO,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;AAC/B,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/server-workers",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Cloudflare Workers voice host for Syrinx — the full STT→LLM→TTS engine in one hibernatable Durable Object per session, with R2 call recording",
|
|
6
6
|
"keywords": [
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"url": "https://github.com/kuralle/syrinx/issues"
|
|
24
24
|
},
|
|
25
25
|
"type": "module",
|
|
26
|
-
"main": "./
|
|
27
|
-
"types": "./
|
|
26
|
+
"main": "./dist/worker.js",
|
|
27
|
+
"types": "./dist/worker.d.ts",
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@ai-sdk/openai": "^3.0.67",
|
|
30
30
|
"@kuralle-agents/core": "^0.8.5",
|
|
@@ -34,32 +34,39 @@
|
|
|
34
34
|
"agents": "0.14.0",
|
|
35
35
|
"ai": "^6.0.0",
|
|
36
36
|
"zod": "^4.1.8",
|
|
37
|
-
"@kuralle-syrinx/core": "4.
|
|
38
|
-
"@kuralle-syrinx/
|
|
39
|
-
"@kuralle-syrinx/
|
|
40
|
-
"@kuralle-syrinx/
|
|
41
|
-
"@kuralle-syrinx/
|
|
42
|
-
"@kuralle-syrinx/
|
|
43
|
-
"@kuralle-syrinx/
|
|
44
|
-
"@kuralle-syrinx/
|
|
45
|
-
"@kuralle-syrinx/
|
|
46
|
-
"@kuralle-syrinx/
|
|
47
|
-
"@kuralle-syrinx/
|
|
37
|
+
"@kuralle-syrinx/core": "4.5.0",
|
|
38
|
+
"@kuralle-syrinx/cf-agents": "4.5.0",
|
|
39
|
+
"@kuralle-syrinx/cartesia": "4.5.0",
|
|
40
|
+
"@kuralle-syrinx/ws": "4.5.0",
|
|
41
|
+
"@kuralle-syrinx/deepgram": "4.5.0",
|
|
42
|
+
"@kuralle-syrinx/kuralle": "4.5.0",
|
|
43
|
+
"@kuralle-syrinx/realtime": "4.5.0",
|
|
44
|
+
"@kuralle-syrinx/aisdk": "4.5.0",
|
|
45
|
+
"@kuralle-syrinx/pipecat-smart-turn": "4.5.0",
|
|
46
|
+
"@kuralle-syrinx/recorder": "4.5.0",
|
|
47
|
+
"@kuralle-syrinx/server-websocket": "4.5.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@cloudflare/workers-types": "^4.20260601.0",
|
|
51
|
+
"@types/node": "^22.0.0",
|
|
51
52
|
"esbuild": "^0.28.1",
|
|
52
53
|
"miniflare": "^4.20260601.0",
|
|
53
54
|
"typescript": "^5.7.0",
|
|
54
55
|
"vitest": "^3.2.6",
|
|
55
56
|
"wrangler": "^4.19.0"
|
|
56
57
|
},
|
|
58
|
+
"files": [
|
|
59
|
+
"dist",
|
|
60
|
+
"src",
|
|
61
|
+
"README.md"
|
|
62
|
+
],
|
|
57
63
|
"scripts": {
|
|
58
64
|
"typecheck": "tsc --noEmit",
|
|
59
65
|
"test": "vitest run",
|
|
60
66
|
"test:live": "SYRINX_LIVE_WORKER_TEST=1 vitest run",
|
|
61
67
|
"verify:edge-bundle": "../../scripts/verify-edge-bundle.sh",
|
|
62
68
|
"wrangler:dry-run": "wrangler deploy --dry-run",
|
|
63
|
-
"deploy:realtime": "wrangler deploy -c wrangler.realtime.jsonc"
|
|
69
|
+
"deploy:realtime": "wrangler deploy -c wrangler.realtime.jsonc",
|
|
70
|
+
"build": "tsc -p tsconfig.build.json"
|
|
64
71
|
}
|
|
65
72
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
hasRealtimeSessionCredentials,
|
|
10
|
+
realtimeVoicePipeline,
|
|
11
|
+
resolveRealtimeFront,
|
|
12
|
+
} from "./live-realtime-session.js";
|
|
13
|
+
|
|
14
|
+
const srcDir = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
const mockVectorize = {} as import("@cloudflare/workers-types").VectorizeIndex;
|
|
17
|
+
const ctx = { sessionId: "s1" };
|
|
18
|
+
|
|
19
|
+
describe("realtimeVoicePipeline", () => {
|
|
20
|
+
it("is a realtime pipeline routed through the ask_university delegate tool", () => {
|
|
21
|
+
expect(realtimeVoicePipeline.kind).toBe("realtime");
|
|
22
|
+
expect(realtimeVoicePipeline.delegateToolName).toBe("ask_university");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("builds an OpenAI front by default and a Gemini front when REALTIME_FRONT=gemini", () => {
|
|
26
|
+
const openai = realtimeVoicePipeline.front({ OPENAI_API_KEY: "k", VECTORIZE: mockVectorize }, ctx);
|
|
27
|
+
expect(openai).toBeTruthy();
|
|
28
|
+
const gemini = realtimeVoicePipeline.front(
|
|
29
|
+
{ REALTIME_FRONT: "gemini", GEMINI_API_KEY: "g", VECTORIZE: mockVectorize },
|
|
30
|
+
ctx,
|
|
31
|
+
);
|
|
32
|
+
expect(gemini).toBeTruthy();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("requires the front model's key (OPENAI_API_KEY default; GEMINI_API_KEY for gemini)", () => {
|
|
36
|
+
expect(() => realtimeVoicePipeline.front({ VECTORIZE: mockVectorize }, ctx)).toThrow(/OPENAI_API_KEY/);
|
|
37
|
+
expect(() =>
|
|
38
|
+
realtimeVoicePipeline.front({ REALTIME_FRONT: "gemini", VECTORIZE: mockVectorize }, ctx),
|
|
39
|
+
).toThrow(/GEMINI_API_KEY/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("reports credential presence via hasRealtimeSessionCredentials", () => {
|
|
43
|
+
expect(hasRealtimeSessionCredentials({ OPENAI_API_KEY: "k", VECTORIZE: mockVectorize })).toBe(true);
|
|
44
|
+
expect(hasRealtimeSessionCredentials({ VECTORIZE: mockVectorize })).toBe(false);
|
|
45
|
+
expect(hasRealtimeSessionCredentials({
|
|
46
|
+
REALTIME_FRONT: "gemini",
|
|
47
|
+
GEMINI_API_KEY: "g",
|
|
48
|
+
VECTORIZE: mockVectorize,
|
|
49
|
+
})).toBe(true);
|
|
50
|
+
expect(hasRealtimeSessionCredentials({
|
|
51
|
+
REALTIME_FRONT: "gemini",
|
|
52
|
+
VECTORIZE: mockVectorize,
|
|
53
|
+
})).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("defaults REALTIME_FRONT to openai", () => {
|
|
57
|
+
expect(resolveRealtimeFront({ VECTORIZE: mockVectorize })).toBe("openai");
|
|
58
|
+
expect(resolveRealtimeFront({ REALTIME_FRONT: "gemini", VECTORIZE: mockVectorize })).toBe("gemini");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("realtime worker edge safety", () => {
|
|
63
|
+
it("live-realtime-session and worker-realtime stay free of Node-only primitives", () => {
|
|
64
|
+
const files = ["live-realtime-session.ts", "worker-realtime.ts", "kuralle-realtime-agent.ts"];
|
|
65
|
+
const banned = /\bBuffer\b|from "node:|process\./;
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
const source = readFileSync(path.join(srcDir, file), "utf8");
|
|
68
|
+
for (const [index, line] of source.split("\n").entries()) {
|
|
69
|
+
expect(line, `${file}:${index + 1}`).not.toMatch(banned);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("does not pull node: imports into the realtime worker src tree", () => {
|
|
75
|
+
const nodeImports = readdirSync(srcDir)
|
|
76
|
+
.filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts"))
|
|
77
|
+
.flatMap((name) => {
|
|
78
|
+
const source = readFileSync(path.join(srcDir, name), "utf8");
|
|
79
|
+
return source
|
|
80
|
+
.split("\n")
|
|
81
|
+
.filter((line) => line.includes('from "node:'))
|
|
82
|
+
.map((line) => `${name}: ${line.trim()}`);
|
|
83
|
+
});
|
|
84
|
+
expect(nodeImports.filter((entry) => entry.startsWith("live-realtime-session")
|
|
85
|
+
|| entry.startsWith("worker-realtime")
|
|
86
|
+
|| entry.startsWith("kuralle-realtime-agent"))).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { DeepgramSTTPlugin, DeepgramTTSPlugin } from "@kuralle-syrinx/deepgram";
|
|
8
|
+
import { PipecatEOSPlugin } from "@kuralle-syrinx/pipecat-smart-turn/eos";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
createLiveReasoner,
|
|
12
|
+
createWorkersAiSmartTurnPredictor,
|
|
13
|
+
hasLiveSessionCredentials,
|
|
14
|
+
liveCascadedPipeline,
|
|
15
|
+
type LiveSessionEnv,
|
|
16
|
+
} from "./live-session.js";
|
|
17
|
+
import type { Ai } from "./workers-ai-smart-turn.js";
|
|
18
|
+
|
|
19
|
+
const srcDir = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const mockVectorize = {} as import("@cloudflare/workers-types").VectorizeIndex;
|
|
21
|
+
const ctx = { sessionId: "s1" };
|
|
22
|
+
|
|
23
|
+
function resolveOwner(env: LiveSessionEnv): "provider_stt" | "smart_turn" {
|
|
24
|
+
const owner = liveCascadedPipeline.endpointingOwner;
|
|
25
|
+
return typeof owner === "function" ? owner(env) : (owner ?? "provider_stt");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const baseEnv = (): LiveSessionEnv => ({
|
|
29
|
+
DEEPGRAM_API_KEY: "dg-key",
|
|
30
|
+
OPENAI_API_KEY: "oa-key",
|
|
31
|
+
VECTORIZE: mockVectorize,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const mockAi = (): Ai => ({
|
|
35
|
+
run: async () => ({ is_complete: true, probability: 0.9 }),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("liveCascadedPipeline", () => {
|
|
39
|
+
it("is a cascaded pipeline with a tightened force-finalize timeout", () => {
|
|
40
|
+
expect(liveCascadedPipeline.kind).toBe("cascaded");
|
|
41
|
+
expect(liveCascadedPipeline.sttForceFinalizeTimeoutMs).toBe(3500);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("defaults to provider_stt with no eos when env.AI is absent", () => {
|
|
45
|
+
const env = baseEnv();
|
|
46
|
+
expect(resolveOwner(env)).toBe("provider_stt");
|
|
47
|
+
expect(liveCascadedPipeline.eos?.(env, ctx)).toBeUndefined();
|
|
48
|
+
expect(createWorkersAiSmartTurnPredictor(env)).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("selects smart_turn + PipecatEOSPlugin when env.AI is present", () => {
|
|
52
|
+
const env: LiveSessionEnv = { ...baseEnv(), AI: mockAi() };
|
|
53
|
+
expect(resolveOwner(env)).toBe("smart_turn");
|
|
54
|
+
expect(createWorkersAiSmartTurnPredictor(env)).toBeTruthy();
|
|
55
|
+
|
|
56
|
+
const eos = liveCascadedPipeline.eos?.(env, ctx);
|
|
57
|
+
expect(eos).toBeDefined();
|
|
58
|
+
expect(eos!.plugin).toBeInstanceOf(PipecatEOSPlugin);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("builds Deepgram Nova-3 STT (VAD events) and Aura TTS stages from the env", () => {
|
|
62
|
+
const env = baseEnv();
|
|
63
|
+
const stt = liveCascadedPipeline.stt(env, ctx);
|
|
64
|
+
expect(stt.plugin).toBeInstanceOf(DeepgramSTTPlugin);
|
|
65
|
+
expect(stt.config).toMatchObject({ model: "nova-3", endpointing: 500, vad_events: true, utterance_end_ms: 1000, api_key: "dg-key" });
|
|
66
|
+
|
|
67
|
+
const tts = liveCascadedPipeline.tts(env, ctx);
|
|
68
|
+
expect(tts.plugin).toBeInstanceOf(DeepgramTTSPlugin);
|
|
69
|
+
expect(tts.config).toMatchObject({ model: "aura-2-thalia-en", api_key: "dg-key" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("requires DEEPGRAM_API_KEY to build the stt/tts stages", () => {
|
|
73
|
+
const env = { OPENAI_API_KEY: "k", VECTORIZE: mockVectorize };
|
|
74
|
+
expect(() => liveCascadedPipeline.stt(env, ctx)).toThrow(/DEEPGRAM_API_KEY/);
|
|
75
|
+
expect(() => liveCascadedPipeline.tts(env, ctx)).toThrow(/DEEPGRAM_API_KEY/);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("createLiveReasoner", () => {
|
|
80
|
+
it("requires OPENAI_API_KEY and VECTORIZE", async () => {
|
|
81
|
+
await expect(createLiveReasoner({ VECTORIZE: mockVectorize }, ctx)).rejects.toThrow(/OPENAI_API_KEY/);
|
|
82
|
+
await expect(
|
|
83
|
+
createLiveReasoner(
|
|
84
|
+
{ OPENAI_API_KEY: "k", VECTORIZE: undefined as unknown as typeof mockVectorize },
|
|
85
|
+
ctx,
|
|
86
|
+
),
|
|
87
|
+
).rejects.toThrow(/VECTORIZE/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("reports credential presence via hasLiveSessionCredentials", () => {
|
|
91
|
+
expect(hasLiveSessionCredentials({
|
|
92
|
+
DEEPGRAM_API_KEY: "d",
|
|
93
|
+
OPENAI_API_KEY: "o",
|
|
94
|
+
VECTORIZE: mockVectorize,
|
|
95
|
+
})).toBe(true);
|
|
96
|
+
expect(hasLiveSessionCredentials({
|
|
97
|
+
OPENAI_API_KEY: "o",
|
|
98
|
+
VECTORIZE: mockVectorize,
|
|
99
|
+
})).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("cascade worker edge safety", () => {
|
|
104
|
+
it("live-session and worker stay free of Node-only primitives", () => {
|
|
105
|
+
const files = ["live-session.ts", "worker.ts", "kuralle-realtime-agent.ts"];
|
|
106
|
+
const banned = /\bBuffer\b|from "node:|process\./;
|
|
107
|
+
for (const file of files) {
|
|
108
|
+
const source = readFileSync(path.join(srcDir, file), "utf8");
|
|
109
|
+
for (const [index, line] of source.split("\n").entries()) {
|
|
110
|
+
expect(line, `${file}:${index + 1}`).not.toMatch(banned);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("does not pull node: imports into the cascade worker src tree", () => {
|
|
116
|
+
const nodeImports = readdirSync(srcDir)
|
|
117
|
+
.filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts"))
|
|
118
|
+
.flatMap((name) => {
|
|
119
|
+
const source = readFileSync(path.join(srcDir, name), "utf8");
|
|
120
|
+
return source
|
|
121
|
+
.split("\n")
|
|
122
|
+
.filter((line) => line.includes('from "node:'))
|
|
123
|
+
.map((line) => `${name}: ${line.trim()}`);
|
|
124
|
+
});
|
|
125
|
+
expect(nodeImports.filter((entry) => entry.startsWith("live-session")
|
|
126
|
+
|| entry.startsWith("worker.ts")
|
|
127
|
+
|| entry.startsWith("kuralle-realtime-agent"))).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
});
|