create-syrinx-agent 4.4.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 +22 -0
- package/README.md +91 -0
- package/dist/index.js +1100 -0
- package/dist/index.js.map +7 -0
- package/package.json +45 -0
- package/src/cli.ts +76 -0
- package/src/exit-codes.ts +20 -0
- package/src/generate.ts +35 -0
- package/src/help.ts +45 -0
- package/src/index.ts +16 -0
- package/src/options.test.ts +130 -0
- package/src/options.ts +176 -0
- package/src/providers/realtime.ts +31 -0
- package/src/providers/reasoner.ts +69 -0
- package/src/providers/stt.ts +68 -0
- package/src/providers/tts.ts +69 -0
- package/src/providers/types.ts +58 -0
- package/src/providers/vad-endpointing.ts +36 -0
- package/src/templates/agent-module.ts +188 -0
- package/src/templates/agents-md.ts +65 -0
- package/src/templates/cloudflare-worker.ts +172 -0
- package/src/templates/dev-server.ts +104 -0
- package/src/templates/env-example.ts +58 -0
- package/src/templates/fixture.ts +34 -0
- package/src/templates/package-json.ts +119 -0
- package/src/templates/tsconfig.ts +25 -0
- package/src/version.ts +5 -0
- package/src/write-project.test.ts +163 -0
- package/src/write-project.ts +59 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Emits src/agent.ts for the node runtime — the module the CLI's --agent
|
|
4
|
+
// seam points at (`tsx scripts/dev-server.ts --agent ./src/agent.ts#createAgent`,
|
|
5
|
+
// `syrinx turn --agent ./src/agent.ts#createAgent`). Mirrors
|
|
6
|
+
// examples/02-hello-voice-headless/src/hello-voice-agent.ts (cascade) and
|
|
7
|
+
// scripts/run-realtime-oneturn-smoke.ts (realtime).
|
|
8
|
+
|
|
9
|
+
import type { ResolvedOptions } from "../options.js";
|
|
10
|
+
import { STT_STAGE_PROVIDERS } from "../providers/stt.js";
|
|
11
|
+
import { TTS_STAGE_PROVIDERS } from "../providers/tts.js";
|
|
12
|
+
import { REASONER_PROVIDERS, SYSTEM_PROMPT_CONST } from "../providers/reasoner.js";
|
|
13
|
+
import { REALTIME_PROVIDERS } from "../providers/realtime.js";
|
|
14
|
+
import { VAD_SIDECAR_PROVIDERS, ENDPOINTING_SIDECAR_PROVIDERS } from "../providers/vad-endpointing.js";
|
|
15
|
+
import { CliError, EXIT_CODES } from "../exit-codes.js";
|
|
16
|
+
import type { StageProvider } from "../providers/types.js";
|
|
17
|
+
|
|
18
|
+
const SYSTEM_PROMPT = "You are a helpful voice assistant. Keep your replies short.";
|
|
19
|
+
|
|
20
|
+
function nodeEnvRef(key: string): string {
|
|
21
|
+
return `process.env["${key}"] ?? ""`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function requireStage(provider: StageProvider | undefined, flag: string, value: string): StageProvider {
|
|
25
|
+
if (provider === undefined) {
|
|
26
|
+
throw new CliError(EXIT_CODES.USAGE, `--${flag} ${value} is not yet implemented by this generator`);
|
|
27
|
+
}
|
|
28
|
+
return provider;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function importLine(className: string, from: string): string {
|
|
32
|
+
return `import { ${className} } from "${from}";`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Merge `import { A } from "x"; import { B } from "x";` pairs sharing one module into one line. */
|
|
36
|
+
function mergeImports(lines: readonly string[]): string[] {
|
|
37
|
+
const IMPORT_RE = /^import \{ (.+) \} from "(.+)";$/;
|
|
38
|
+
const sourceOrder: string[] = [];
|
|
39
|
+
const namesBySource = new Map<string, string[]>();
|
|
40
|
+
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
const match = IMPORT_RE.exec(line);
|
|
43
|
+
const names = match?.[1];
|
|
44
|
+
const from = match?.[2];
|
|
45
|
+
if (names === undefined || from === undefined) continue; // every entry here is an import line
|
|
46
|
+
let bucket = namesBySource.get(from);
|
|
47
|
+
if (bucket === undefined) {
|
|
48
|
+
bucket = [];
|
|
49
|
+
namesBySource.set(from, bucket);
|
|
50
|
+
sourceOrder.push(from);
|
|
51
|
+
}
|
|
52
|
+
for (const name of names.split(",").map((n) => n.trim())) {
|
|
53
|
+
if (!bucket.includes(name)) bucket.push(name);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return sourceOrder.map((from) => `import { ${(namesBySource.get(from) ?? []).join(", ")} } from "${from}";`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildAgentModule(opts: ResolvedOptions): string {
|
|
61
|
+
if (opts.mode === "realtime") return buildRealtimeAgentModule(opts);
|
|
62
|
+
return buildCascadeAgentModule(opts);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildCascadeAgentModule(opts: Extract<ResolvedOptions, { mode: "cascade" }>): string {
|
|
66
|
+
const stt = requireStage(STT_STAGE_PROVIDERS[opts.stt], "stt", opts.stt);
|
|
67
|
+
const tts = requireStage(TTS_STAGE_PROVIDERS[opts.tts], "tts", opts.tts);
|
|
68
|
+
const reasoner = REASONER_PROVIDERS[opts.reasoner];
|
|
69
|
+
if (reasoner === undefined) {
|
|
70
|
+
throw new CliError(EXIT_CODES.USAGE, `--reasoner ${opts.reasoner} is not yet implemented by this generator`);
|
|
71
|
+
}
|
|
72
|
+
const vad = opts.vad !== undefined ? VAD_SIDECAR_PROVIDERS[opts.vad] : undefined;
|
|
73
|
+
if (opts.vad !== undefined && vad === undefined) {
|
|
74
|
+
throw new CliError(EXIT_CODES.USAGE, `--vad ${opts.vad} is not yet implemented by this generator`);
|
|
75
|
+
}
|
|
76
|
+
const eos = opts.endpointing !== undefined ? ENDPOINTING_SIDECAR_PROVIDERS[opts.endpointing] : undefined;
|
|
77
|
+
if (opts.endpointing !== undefined && eos === undefined) {
|
|
78
|
+
throw new CliError(EXIT_CODES.USAGE, `--endpointing ${opts.endpointing} is not yet implemented by this generator`);
|
|
79
|
+
}
|
|
80
|
+
const endpointingOwner = eos !== undefined ? "smart_turn" : stt.ownsEndpointing ? "provider_stt" : undefined;
|
|
81
|
+
if (endpointingOwner === undefined) {
|
|
82
|
+
throw new CliError(
|
|
83
|
+
EXIT_CODES.USAGE,
|
|
84
|
+
`--stt ${opts.stt} cannot own endpointing on its own — pass --endpointing pipecat-smart-turn`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const imports = [
|
|
89
|
+
`import { VoiceAgentSession, type VoicePlugin } from "@kuralle-syrinx/core";`,
|
|
90
|
+
`import { ReasoningBridge } from "@kuralle-syrinx/aisdk";`,
|
|
91
|
+
importLine(stt.className, stt.importFrom ?? stt.packageName),
|
|
92
|
+
importLine(tts.className, tts.importFrom ?? tts.packageName),
|
|
93
|
+
...(vad !== undefined ? [importLine(vad.className, vad.packageName)] : []),
|
|
94
|
+
...(eos !== undefined ? [importLine(eos.className, eos.packageName)] : []),
|
|
95
|
+
...reasoner.importLines(nodeEnvRef),
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const pluginConfigLines: string[] = [
|
|
99
|
+
` stt: {`,
|
|
100
|
+
...stt.configFields(nodeEnvRef).map((f) => ` ${f},`),
|
|
101
|
+
` },`,
|
|
102
|
+
];
|
|
103
|
+
if (vad !== undefined) pluginConfigLines.push(` vad: {},`);
|
|
104
|
+
if (eos !== undefined) pluginConfigLines.push(` eos: {},`);
|
|
105
|
+
pluginConfigLines.push(
|
|
106
|
+
` bridge: {},`,
|
|
107
|
+
` tts: {`,
|
|
108
|
+
...tts.configFields(nodeEnvRef).map((f) => ` ${f},`),
|
|
109
|
+
` },`,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const pluginInstanceLines: string[] = [` stt: new ${stt.className}(),`];
|
|
113
|
+
if (vad !== undefined) pluginInstanceLines.push(` vad: new ${vad.className}(),`);
|
|
114
|
+
if (eos !== undefined) pluginInstanceLines.push(` eos: new ${eos.className}(),`);
|
|
115
|
+
pluginInstanceLines.push(
|
|
116
|
+
` bridge: new ReasoningBridge(${reasoner.reasonerExpr}),`,
|
|
117
|
+
` tts: new ${tts.className}(),`,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
return `// SPDX-License-Identifier: MIT
|
|
121
|
+
//
|
|
122
|
+
// Cascade voice agent: ${opts.stt} STT -> ${opts.reasoner} reasoner -> ${opts.tts} TTS.
|
|
123
|
+
// Generated by create-syrinx-agent. Wired to the local dev server and to
|
|
124
|
+
// \`syrinx turn\`/\`syrinx text\` through the --agent <module>#<export> seam
|
|
125
|
+
// (examples/02-hello-voice-headless/scripts/dev-server.ts).
|
|
126
|
+
|
|
127
|
+
${mergeImports(imports).join("\n")}
|
|
128
|
+
|
|
129
|
+
const ${SYSTEM_PROMPT_CONST} = ${JSON.stringify(SYSTEM_PROMPT)};
|
|
130
|
+
|
|
131
|
+
export function createAgent(): VoiceAgentSession {
|
|
132
|
+
${reasoner.preludeLines(nodeEnvRef).map((l) => ` ${l}`).join("\n")}
|
|
133
|
+
|
|
134
|
+
const session = new VoiceAgentSession({
|
|
135
|
+
plugins: {
|
|
136
|
+
${pluginConfigLines.join("\n")}
|
|
137
|
+
},
|
|
138
|
+
endpointingOwner: "${endpointingOwner}",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const plugins: Record<string, VoicePlugin> = {
|
|
142
|
+
${pluginInstanceLines.join("\n")}
|
|
143
|
+
};
|
|
144
|
+
for (const [name, plugin] of Object.entries(plugins)) {
|
|
145
|
+
session.registerPlugin(name, plugin);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return session;
|
|
149
|
+
}
|
|
150
|
+
`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildRealtimeAgentModule(opts: Extract<ResolvedOptions, { mode: "realtime" }>): string {
|
|
154
|
+
const realtime = REALTIME_PROVIDERS[opts.realtime];
|
|
155
|
+
if (realtime === undefined) {
|
|
156
|
+
throw new CliError(EXIT_CODES.USAGE, `--realtime ${opts.realtime} is not yet implemented by this generator`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const imports = [
|
|
160
|
+
`import { VoiceAgentSession } from "@kuralle-syrinx/core";`,
|
|
161
|
+
`import { RealtimeBridge } from "@kuralle-syrinx/realtime";`,
|
|
162
|
+
`import { createNodeWsSocket } from "@kuralle-syrinx/ws/node";`,
|
|
163
|
+
...realtime.importLines,
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
return `// SPDX-License-Identifier: MIT
|
|
167
|
+
//
|
|
168
|
+
// Speech-to-speech voice agent: ${opts.realtime} realtime. Generated by
|
|
169
|
+
// create-syrinx-agent. Wired to the local dev server and to
|
|
170
|
+
// \`syrinx turn\`/\`syrinx text\` through the --agent <module>#<export> seam
|
|
171
|
+
// (examples/02-hello-voice-headless/scripts/dev-server.ts).
|
|
172
|
+
|
|
173
|
+
${mergeImports(imports).join("\n")}
|
|
174
|
+
|
|
175
|
+
export function createAgent(): VoiceAgentSession {
|
|
176
|
+
const adapter = ${realtime.adapterExpr(nodeEnvRef, "createNodeWsSocket")};
|
|
177
|
+
const bridge = new RealtimeBridge(adapter);
|
|
178
|
+
|
|
179
|
+
const session = new VoiceAgentSession({
|
|
180
|
+
plugins: { realtime: {} },
|
|
181
|
+
endpointingOwner: "timer",
|
|
182
|
+
});
|
|
183
|
+
session.registerPlugin("realtime", bridge);
|
|
184
|
+
|
|
185
|
+
return session;
|
|
186
|
+
}
|
|
187
|
+
`;
|
|
188
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Builds AGENTS.md — must state plainly what a coding agent CANNOT verify
|
|
4
|
+
// headlessly (LDT-21 requirement). An AGENTS.md implying headless
|
|
5
|
+
// verification is complete is worse than shipping none.
|
|
6
|
+
|
|
7
|
+
import type { ResolvedOptions } from "../options.js";
|
|
8
|
+
|
|
9
|
+
export function buildAgentsMd(opts: ResolvedOptions): string {
|
|
10
|
+
const pipelineLine =
|
|
11
|
+
opts.mode === "cascade"
|
|
12
|
+
? `${opts.stt} STT -> ${opts.reasoner} reasoner -> ${opts.tts} TTS (cascade)`
|
|
13
|
+
: `${opts.realtime} realtime (speech-to-speech, no separate STT/TTS stages)`;
|
|
14
|
+
|
|
15
|
+
return `# AGENTS.md
|
|
16
|
+
|
|
17
|
+
This project was generated by \`create-syrinx-agent\`.
|
|
18
|
+
|
|
19
|
+
Pipeline: ${pipelineLine}
|
|
20
|
+
Transport: ${opts.transport} · Runtime: ${opts.runtime}
|
|
21
|
+
|
|
22
|
+
## What a coding agent CAN verify here
|
|
23
|
+
|
|
24
|
+
- \`pnpm check:typecheck\` (\`tsc --noEmit\`) — the agent module and dev server compile.
|
|
25
|
+
- \`pnpm check:turn\` — runs \`syrinx turn --in test/fixtures/smoke.wav --agent ./src/agent.ts#createAgent --json\`.
|
|
26
|
+
Given a fixture **with** a recorded expected transcript (a \`.json\` sidecar produced by the
|
|
27
|
+
Studio's "Save as fixture"), this asserts the replayed transcript matches and **exits non-zero
|
|
28
|
+
on drift** — a real regression check, not just a smoke test. The bundled \`test/fixtures/smoke.wav\`
|
|
29
|
+
ships with no sidecar, so as generated this only proves the pipeline runs end-to-end and produces
|
|
30
|
+
*some* transcript/reply/audio; record your own fixture with the Studio for an assertion that
|
|
31
|
+
actually catches regressions.
|
|
32
|
+
- Exit codes and JSON output from \`syrinx turn\` / \`syrinx text\` — parseable, deterministic,
|
|
33
|
+
scriptable.
|
|
34
|
+
|
|
35
|
+
Both checks need real provider credentials in \`.env\` (see \`.env.example\`) — without them
|
|
36
|
+
\`check:turn\` fails at the provider call, not at a code defect.
|
|
37
|
+
|
|
38
|
+
## What a coding agent CANNOT verify here
|
|
39
|
+
|
|
40
|
+
A coding agent can assert transcripts, exit codes, and latency numbers. It **cannot** judge:
|
|
41
|
+
|
|
42
|
+
- Whether barge-in feels natural (the timing a human perceives as responsive vs. jarring).
|
|
43
|
+
- Whether a pause reads as "thinking" or as a hang — same latency number, different human read.
|
|
44
|
+
- Whether a synthesized voice sounds warm, robotic, or off — TTS quality is a perceptual judgment.
|
|
45
|
+
|
|
46
|
+
Those need a human in the Syrinx Studio, listening. Do not treat a green \`check:turn\` as proof the
|
|
47
|
+
agent is pleasant to talk to — it only proves the transcript matched (or that the pipeline ran, for
|
|
48
|
+
the unsigned bundled fixture).
|
|
49
|
+
|
|
50
|
+
## \`tsx\` vs plain \`node\`
|
|
51
|
+
|
|
52
|
+
\`--agent\` pointed at a \`.ts\` module (as in the scripts above) needs \`tsx\` — Node's built-in type
|
|
53
|
+
stripping does not do TypeScript's \`.js\`-import-resolves-to-\`.ts\` remapping that this project's
|
|
54
|
+
imports rely on. Built JS (after a bundler step) runs fine under plain \`node\`. Every script this
|
|
55
|
+
generator wrote already invokes \`tsx\`; if you write your own, do the same for a \`.ts\` --agent target.
|
|
56
|
+
|
|
57
|
+
## Layout
|
|
58
|
+
|
|
59
|
+
- \`src/agent.ts\` — \`createAgent()\`, the \`--agent\` seam's factory export.
|
|
60
|
+
- \`scripts/dev-server.ts\` — local dev server (\`pnpm dev\`), wraps \`createAgent()\` in a
|
|
61
|
+
${opts.transport} transport host.
|
|
62
|
+
- \`test/fixtures/smoke.wav\` — bundled silence fixture for \`check:turn\`.
|
|
63
|
+
- \`.env.example\` — exactly the provider keys this combination needs.
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Emits src/index.ts + wrangler.jsonc for --runtime cloudflare, mirroring
|
|
4
|
+
// examples/03-cf-agent-voice/src/index.ts and packages/cf-agents/README.md's
|
|
5
|
+
// `withVoice(Agent, { pipeline, reasoner, transport })` shape. VAD/endpointing
|
|
6
|
+
// sidecars are not wired here yet (see the generator's report) — passing
|
|
7
|
+
// --vad/--endpointing with --runtime cloudflare is refused upstream.
|
|
8
|
+
|
|
9
|
+
import type { ResolvedOptions, Transport } from "../options.js";
|
|
10
|
+
import { STT_STAGE_PROVIDERS } from "../providers/stt.js";
|
|
11
|
+
import { TTS_STAGE_PROVIDERS } from "../providers/tts.js";
|
|
12
|
+
import { REASONER_PROVIDERS, SYSTEM_PROMPT_CONST } from "../providers/reasoner.js";
|
|
13
|
+
import { REALTIME_PROVIDERS } from "../providers/realtime.js";
|
|
14
|
+
import { CliError, EXIT_CODES } from "../exit-codes.js";
|
|
15
|
+
import type { EnvKeySpec, StageProvider } from "../providers/types.js";
|
|
16
|
+
import { collectEnvKeys } from "./env-example.js";
|
|
17
|
+
|
|
18
|
+
const SYSTEM_PROMPT = "You are a helpful voice assistant. Keep your replies short.";
|
|
19
|
+
|
|
20
|
+
function envRef(key: string): string {
|
|
21
|
+
return `env.${key}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function importLine(className: string, from: string): string {
|
|
25
|
+
return `import { ${className} } from "${from}";`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function requireStage(provider: StageProvider | undefined, flag: string, value: string): StageProvider {
|
|
29
|
+
if (provider === undefined) {
|
|
30
|
+
throw new CliError(EXIT_CODES.USAGE, `--${flag} ${value} is not yet implemented by this generator`);
|
|
31
|
+
}
|
|
32
|
+
return provider;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `withVoice`'s documented `transport` option: "edge" | "twilio" | "telnyx" — no "smartpbx". */
|
|
36
|
+
function cfTransport(transport: Transport): "edge" | "twilio" | "telnyx" {
|
|
37
|
+
if (transport === "browser") return "edge";
|
|
38
|
+
if (transport === "twilio" || transport === "telnyx") return transport;
|
|
39
|
+
throw new CliError(
|
|
40
|
+
EXIT_CODES.USAGE,
|
|
41
|
+
`--runtime cloudflare has no ${transport} transport binding in @kuralle-syrinx/cf-agents (only edge/twilio/telnyx) — use --transport browser, twilio, or telnyx`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function envInterface(keys: readonly EnvKeySpec[]): string {
|
|
46
|
+
const lines = keys.map((k) => ` readonly ${k.key}: string;`);
|
|
47
|
+
return `interface Env extends Record<string, unknown> {\n${lines.join("\n")}\n}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildCloudflareWorkerSource(opts: ResolvedOptions): string {
|
|
51
|
+
const transport = cfTransport(opts.transport);
|
|
52
|
+
const envKeys = collectEnvKeys(opts);
|
|
53
|
+
|
|
54
|
+
if (opts.mode === "cascade") {
|
|
55
|
+
if (opts.vad !== undefined || opts.endpointing !== undefined) {
|
|
56
|
+
throw new CliError(
|
|
57
|
+
EXIT_CODES.USAGE,
|
|
58
|
+
"--vad/--endpointing are not yet implemented for --runtime cloudflare by this generator",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const stt = requireStage(STT_STAGE_PROVIDERS[opts.stt], "stt", opts.stt);
|
|
62
|
+
const tts = requireStage(TTS_STAGE_PROVIDERS[opts.tts], "tts", opts.tts);
|
|
63
|
+
const reasoner = REASONER_PROVIDERS[opts.reasoner];
|
|
64
|
+
if (reasoner === undefined) {
|
|
65
|
+
throw new CliError(EXIT_CODES.USAGE, `--reasoner ${opts.reasoner} is not yet implemented by this generator`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sttNew = stt.usesSocketFactory ? `new ${stt.className}(createWorkersSocket)` : `new ${stt.className}()`;
|
|
69
|
+
const ttsNew = tts.usesSocketFactory ? `new ${tts.className}(createWorkersSocket)` : `new ${tts.className}()`;
|
|
70
|
+
const needsWorkersSocket = stt.usesSocketFactory || tts.usesSocketFactory;
|
|
71
|
+
|
|
72
|
+
const imports = [
|
|
73
|
+
`import { Agent, routeAgentRequest } from "agents";`,
|
|
74
|
+
`import { withVoice } from "@kuralle-syrinx/cf-agents";`,
|
|
75
|
+
importLine(stt.className, stt.importFrom ?? stt.packageName),
|
|
76
|
+
importLine(tts.className, tts.importFrom ?? tts.packageName),
|
|
77
|
+
...(needsWorkersSocket ? [`import { createWorkersSocket } from "@kuralle-syrinx/ws/workers";`] : []),
|
|
78
|
+
...reasoner.importLines(envRef),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
return `// SPDX-License-Identifier: MIT
|
|
82
|
+
//
|
|
83
|
+
// Cascade voice agent on Cloudflare Workers: ${opts.stt} STT -> ${opts.reasoner} reasoner -> ${opts.tts} TTS.
|
|
84
|
+
// Generated by create-syrinx-agent. Mirrors examples/03-cf-agent-voice and
|
|
85
|
+
// packages/cf-agents/README.md.
|
|
86
|
+
|
|
87
|
+
${imports.join("\n")}
|
|
88
|
+
|
|
89
|
+
${envInterface(envKeys)}
|
|
90
|
+
|
|
91
|
+
const ${SYSTEM_PROMPT_CONST} = ${JSON.stringify(SYSTEM_PROMPT)};
|
|
92
|
+
|
|
93
|
+
export class VoiceAgent extends withVoice<Env, typeof Agent<Env>>(Agent<Env>, {
|
|
94
|
+
transport: "${transport}",
|
|
95
|
+
pipeline: {
|
|
96
|
+
kind: "cascaded",
|
|
97
|
+
stt: (env) => ({
|
|
98
|
+
plugin: ${sttNew},
|
|
99
|
+
config: {
|
|
100
|
+
${stt.configFields(envRef).map((f) => ` ${f},`).join("\n")}
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
tts: (env) => ({
|
|
104
|
+
plugin: ${ttsNew},
|
|
105
|
+
config: {
|
|
106
|
+
${tts.configFields(envRef).map((f) => ` ${f},`).join("\n")}
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
},
|
|
110
|
+
reasoner: (env) => {
|
|
111
|
+
${reasoner.preludeLines(envRef).map((l) => ` ${l}`).join("\n")}
|
|
112
|
+
return ${reasoner.reasonerExpr};
|
|
113
|
+
},
|
|
114
|
+
}) {}
|
|
115
|
+
|
|
116
|
+
export default {
|
|
117
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
118
|
+
return (await routeAgentRequest(request, env)) ?? new Response("Not found", { status: 404 });
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const realtime = REALTIME_PROVIDERS[opts.realtime];
|
|
125
|
+
if (realtime === undefined) {
|
|
126
|
+
throw new CliError(EXIT_CODES.USAGE, `--realtime ${opts.realtime} is not yet implemented by this generator`);
|
|
127
|
+
}
|
|
128
|
+
const imports = [
|
|
129
|
+
`import { Agent, routeAgentRequest } from "agents";`,
|
|
130
|
+
`import { withVoice } from "@kuralle-syrinx/cf-agents";`,
|
|
131
|
+
`import { createWorkersSocket } from "@kuralle-syrinx/ws/workers";`,
|
|
132
|
+
...realtime.importLines,
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
return `// SPDX-License-Identifier: MIT
|
|
136
|
+
//
|
|
137
|
+
// Speech-to-speech voice agent on Cloudflare Workers: ${opts.realtime} realtime.
|
|
138
|
+
// Generated by create-syrinx-agent. Mirrors examples/03-cf-agent-voice and
|
|
139
|
+
// packages/cf-agents/README.md.
|
|
140
|
+
|
|
141
|
+
${imports.join("\n")}
|
|
142
|
+
|
|
143
|
+
${envInterface(envKeys)}
|
|
144
|
+
|
|
145
|
+
export class VoiceAgent extends withVoice<Env, typeof Agent<Env>>(Agent<Env>, {
|
|
146
|
+
transport: "${transport}",
|
|
147
|
+
pipeline: {
|
|
148
|
+
kind: "realtime",
|
|
149
|
+
front: (env) => ${realtime.adapterExpr(envRef, "createWorkersSocket")},
|
|
150
|
+
},
|
|
151
|
+
}) {}
|
|
152
|
+
|
|
153
|
+
export default {
|
|
154
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
155
|
+
return (await routeAgentRequest(request, env)) ?? new Response("Not found", { status: 404 });
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function buildWranglerJsonc(opts: ResolvedOptions): string {
|
|
162
|
+
const wrangler = {
|
|
163
|
+
$schema: "node_modules/wrangler/config-schema.json",
|
|
164
|
+
name: opts.name,
|
|
165
|
+
main: "src/index.ts",
|
|
166
|
+
compatibility_date: "2026-06-01",
|
|
167
|
+
compatibility_flags: ["nodejs_compat"],
|
|
168
|
+
durable_objects: { bindings: [{ name: "VoiceAgent", class_name: "VoiceAgent" }] },
|
|
169
|
+
migrations: [{ tag: "v1", new_sqlite_classes: ["VoiceAgent"] }],
|
|
170
|
+
};
|
|
171
|
+
return `${JSON.stringify(wrangler, null, 2)}\n`;
|
|
172
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Emits scripts/dev-server.ts for the node runtime — a transport host that
|
|
4
|
+
// resolves --agent <module>[#export] exactly like
|
|
5
|
+
// examples/02-hello-voice-headless/scripts/dev-server.ts, minus that example's
|
|
6
|
+
// Studio-asset serving (this is a standalone project, not inside the monorepo;
|
|
7
|
+
// bring your own browser client, e.g. @kuralle-syrinx/browser-client).
|
|
8
|
+
|
|
9
|
+
import type { Transport } from "../options.js";
|
|
10
|
+
|
|
11
|
+
interface TransportServerBinding {
|
|
12
|
+
readonly factoryName: string;
|
|
13
|
+
readonly extraOptions: readonly string[];
|
|
14
|
+
readonly urlPath: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TRANSPORT_SERVERS: Readonly<Record<Transport, TransportServerBinding>> = {
|
|
18
|
+
browser: { factoryName: "createVoiceWebSocketServer", extraOptions: [`path: "/ws"`], urlPath: "/ws" },
|
|
19
|
+
twilio: { factoryName: "createTwilioMediaStreamServer", extraOptions: [], urlPath: "/" },
|
|
20
|
+
telnyx: { factoryName: "createTelnyxMediaStreamServer", extraOptions: [], urlPath: "/" },
|
|
21
|
+
smartpbx: { factoryName: "createSmartPbxMediaStreamServer", extraOptions: [], urlPath: "/" },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function buildDevServer(transport: Transport): string {
|
|
25
|
+
const binding = TRANSPORT_SERVERS[transport];
|
|
26
|
+
const optionsLines = [` port,`, ` host,`, ...binding.extraOptions.map((l) => ` ${l},`), ` createSession: () => factory(),`];
|
|
27
|
+
|
|
28
|
+
return `// SPDX-License-Identifier: MIT
|
|
29
|
+
//
|
|
30
|
+
// Local dev server — wires YOUR agent module to a ${transport} transport host.
|
|
31
|
+
//
|
|
32
|
+
// pnpm dev -- --agent ./src/agent.ts#createAgent
|
|
33
|
+
//
|
|
34
|
+
// --agent is <module>[#namedExport]; the export is a zero-arg factory
|
|
35
|
+
// returning a VoiceAgentSession. Same seam as
|
|
36
|
+
// examples/02-hello-voice-headless/scripts/dev-server.ts.
|
|
37
|
+
|
|
38
|
+
import { isAbsolute, resolve } from "node:path";
|
|
39
|
+
import { pathToFileURL } from "node:url";
|
|
40
|
+
|
|
41
|
+
import { ${binding.factoryName}, installGracefulShutdown } from "@kuralle-syrinx/server-websocket";
|
|
42
|
+
import type { VoiceAgentSession } from "@kuralle-syrinx/core";
|
|
43
|
+
|
|
44
|
+
export type SessionFactory = () => VoiceAgentSession | Promise<VoiceAgentSession>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve \`--agent <module>[#export]\` to a factory. Fails loudly and
|
|
48
|
+
* specifically — a dev server that silently falls back to a different agent
|
|
49
|
+
* than the one you asked for is worse than one that refuses to start.
|
|
50
|
+
*/
|
|
51
|
+
async function resolveAgentFactory(spec: string | undefined): Promise<{ factory: SessionFactory; label: string }> {
|
|
52
|
+
if (!spec) throw new Error("--agent <module>[#export] is required, e.g. --agent ./src/agent.ts#createAgent");
|
|
53
|
+
const [modulePath, exportName] = spec.split("#");
|
|
54
|
+
if (!modulePath) throw new Error(\`--agent needs a module path, got: \${spec}\`);
|
|
55
|
+
const abs = isAbsolute(modulePath) ? modulePath : resolve(process.cwd(), modulePath);
|
|
56
|
+
const mod = (await import(pathToFileURL(abs).href)) as Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
const picked = exportName ? mod[exportName] : (mod["default"] ?? mod["createAgent"] ?? mod["createSession"]);
|
|
59
|
+
if (typeof picked !== "function") {
|
|
60
|
+
const available = Object.keys(mod).filter((k) => typeof mod[k] === "function");
|
|
61
|
+
throw new Error(
|
|
62
|
+
\`\${abs} has no callable export \${exportName ? \`"\${exportName}"\` : "(default, createAgent, or createSession)"}. \` +
|
|
63
|
+
\`Callable exports: \${available.length > 0 ? available.join(", ") : "(none)"}\`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return { factory: picked as SessionFactory, label: \`\${modulePath}\${exportName ? \`#\${exportName}\` : ""}\` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function flag(argv: readonly string[], name: string): string | undefined {
|
|
70
|
+
const i = argv.indexOf(name);
|
|
71
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readPort(): number {
|
|
75
|
+
const raw = process.env["SYRINX_DEV_PORT"]?.trim();
|
|
76
|
+
if (!raw) return 4173;
|
|
77
|
+
const port = Number.parseInt(raw, 10);
|
|
78
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65535) throw new Error(\`invalid SYRINX_DEV_PORT: \${raw}\`);
|
|
79
|
+
return port;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function main(): Promise<void> {
|
|
83
|
+
const { factory, label } = await resolveAgentFactory(flag(process.argv.slice(2), "--agent"));
|
|
84
|
+
const port = readPort();
|
|
85
|
+
const host = process.env["SYRINX_DEV_HOST"]?.trim() || "127.0.0.1";
|
|
86
|
+
|
|
87
|
+
const server = await ${binding.factoryName}({
|
|
88
|
+
${optionsLines.join("\n")}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const address = server.address();
|
|
92
|
+
if (!address || typeof address === "string") throw new Error("expected TCP server address");
|
|
93
|
+
console.log(\`Syrinx dev server (${transport}): ws://\${host}:\${String(address.port)}${binding.urlPath}\`);
|
|
94
|
+
console.log(\`Agent: \${label}\`);
|
|
95
|
+
|
|
96
|
+
installGracefulShutdown(server, { drainDeadlineMs: 10_000, onClosed: () => process.exit(0) });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
void main().catch((err: unknown) => {
|
|
100
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
101
|
+
process.exit(1);
|
|
102
|
+
});
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Builds .env.example naming exactly the keys the chosen combination needs
|
|
4
|
+
// (LDT-21 requirement) — not a union of every provider's keys.
|
|
5
|
+
|
|
6
|
+
import type { ResolvedOptions } from "../options.js";
|
|
7
|
+
import { STT_STAGE_PROVIDERS } from "../providers/stt.js";
|
|
8
|
+
import { TTS_STAGE_PROVIDERS } from "../providers/tts.js";
|
|
9
|
+
import { REASONER_PROVIDERS } from "../providers/reasoner.js";
|
|
10
|
+
import { REALTIME_PROVIDERS } from "../providers/realtime.js";
|
|
11
|
+
import { VAD_SIDECAR_PROVIDERS, ENDPOINTING_SIDECAR_PROVIDERS } from "../providers/vad-endpointing.js";
|
|
12
|
+
import type { EnvKeySpec } from "../providers/types.js";
|
|
13
|
+
|
|
14
|
+
export function collectEnvKeys(opts: ResolvedOptions): readonly EnvKeySpec[] {
|
|
15
|
+
const keys: EnvKeySpec[] = [];
|
|
16
|
+
const seen = new Set<string>();
|
|
17
|
+
const add = (specs: readonly EnvKeySpec[]) => {
|
|
18
|
+
for (const spec of specs) {
|
|
19
|
+
if (seen.has(spec.key)) continue;
|
|
20
|
+
seen.add(spec.key);
|
|
21
|
+
keys.push(spec);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (opts.mode === "cascade") {
|
|
26
|
+
const stt = STT_STAGE_PROVIDERS[opts.stt];
|
|
27
|
+
const tts = TTS_STAGE_PROVIDERS[opts.tts];
|
|
28
|
+
const reasoner = REASONER_PROVIDERS[opts.reasoner];
|
|
29
|
+
if (stt) add(stt.envKeys);
|
|
30
|
+
if (tts) add(tts.envKeys);
|
|
31
|
+
if (reasoner) add(reasoner.envKeys);
|
|
32
|
+
const vad = opts.vad !== undefined ? VAD_SIDECAR_PROVIDERS[opts.vad] : undefined;
|
|
33
|
+
if (vad) add(vad.envKeys);
|
|
34
|
+
const eos = opts.endpointing !== undefined ? ENDPOINTING_SIDECAR_PROVIDERS[opts.endpointing] : undefined;
|
|
35
|
+
if (eos) add(eos.envKeys);
|
|
36
|
+
} else {
|
|
37
|
+
const realtime = REALTIME_PROVIDERS[opts.realtime];
|
|
38
|
+
if (realtime) add(realtime.envKeys);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return keys;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildEnvExample(opts: ResolvedOptions): string {
|
|
45
|
+
const keys = collectEnvKeys(opts);
|
|
46
|
+
const lines = [
|
|
47
|
+
"# Generated by create-syrinx-agent — exactly the keys this combination needs.",
|
|
48
|
+
"# Copy to .env and fill in.",
|
|
49
|
+
"",
|
|
50
|
+
...keys.map((k) => `${k.key}=`),
|
|
51
|
+
"",
|
|
52
|
+
"# Optional — local dev server bind (defaults: 127.0.0.1:4173)",
|
|
53
|
+
"SYRINX_DEV_PORT=",
|
|
54
|
+
"SYRINX_DEV_HOST=",
|
|
55
|
+
"",
|
|
56
|
+
];
|
|
57
|
+
return `${lines.join("\n")}\n`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// A minimal, self-contained mono 16 kHz PCM16 WAV fixture (0.5s of silence)
|
|
4
|
+
// so `check:turn` (syrinx turn --in test/fixtures/smoke.wav) has something to
|
|
5
|
+
// replay without depending on any file outside the generated project. It
|
|
6
|
+
// carries no expected-transcript sidecar — see AGENTS.md for what that means.
|
|
7
|
+
|
|
8
|
+
const SAMPLE_RATE = 16_000;
|
|
9
|
+
const DURATION_SECONDS = 0.5;
|
|
10
|
+
const CHANNELS = 1;
|
|
11
|
+
const BITS_PER_SAMPLE = 16;
|
|
12
|
+
|
|
13
|
+
export function buildSmokeWav(): Buffer {
|
|
14
|
+
const numSamples = Math.floor(SAMPLE_RATE * DURATION_SECONDS);
|
|
15
|
+
const dataBytes = numSamples * CHANNELS * (BITS_PER_SAMPLE / 8);
|
|
16
|
+
const buffer = Buffer.alloc(44 + dataBytes);
|
|
17
|
+
|
|
18
|
+
buffer.write("RIFF", 0, "ascii");
|
|
19
|
+
buffer.writeUInt32LE(36 + dataBytes, 4);
|
|
20
|
+
buffer.write("WAVE", 8, "ascii");
|
|
21
|
+
buffer.write("fmt ", 12, "ascii");
|
|
22
|
+
buffer.writeUInt32LE(16, 16); // fmt chunk size
|
|
23
|
+
buffer.writeUInt16LE(1, 20); // PCM
|
|
24
|
+
buffer.writeUInt16LE(CHANNELS, 22);
|
|
25
|
+
buffer.writeUInt32LE(SAMPLE_RATE, 24);
|
|
26
|
+
buffer.writeUInt32LE(SAMPLE_RATE * CHANNELS * (BITS_PER_SAMPLE / 8), 28); // byte rate
|
|
27
|
+
buffer.writeUInt16LE(CHANNELS * (BITS_PER_SAMPLE / 8), 32); // block align
|
|
28
|
+
buffer.writeUInt16LE(BITS_PER_SAMPLE, 34);
|
|
29
|
+
buffer.write("data", 36, "ascii");
|
|
30
|
+
buffer.writeUInt32LE(dataBytes, 40);
|
|
31
|
+
// PCM16 silence: the buffer is already zero-filled by Buffer.alloc.
|
|
32
|
+
|
|
33
|
+
return buffer;
|
|
34
|
+
}
|