@kuralle-syrinx/cli 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 +152 -0
- package/dist/index.js +1161 -0
- package/dist/index.js.map +7 -0
- package/package.json +53 -0
- package/src/__fixtures__/good-text-agent.mjs +21 -0
- package/src/__fixtures__/no-callable-export.mjs +7 -0
- package/src/__fixtures__/throwing-agent.mjs +8 -0
- package/src/agent-resolve.test.ts +50 -0
- package/src/agent-resolve.ts +80 -0
- package/src/cli.test.ts +154 -0
- package/src/cli.ts +225 -0
- package/src/doctor.test.ts +50 -0
- package/src/doctor.ts +101 -0
- package/src/exit-codes.ts +38 -0
- package/src/fixture.test.ts +100 -0
- package/src/fixture.ts +96 -0
- package/src/help.ts +70 -0
- package/src/index.ts +37 -0
- package/src/text-command.test.ts +32 -0
- package/src/text-command.ts +32 -0
- package/src/text-turn.test.ts +49 -0
- package/src/text-turn.ts +108 -0
- package/src/turn-command.test.ts +80 -0
- package/src/turn-command.ts +81 -0
- package/src/turn-runner.test.ts +111 -0
- package/src/turn-runner.ts +381 -0
- package/src/version.test.ts +69 -0
- package/src/version.ts +92 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
|
|
6
|
+
// src/agent-resolve.ts
|
|
7
|
+
import { isAbsolute, resolve } from "node:path";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
|
|
10
|
+
// src/exit-codes.ts
|
|
11
|
+
var EXIT_CODES = {
|
|
12
|
+
SUCCESS: 0,
|
|
13
|
+
INTERNAL: 1,
|
|
14
|
+
USAGE: 2,
|
|
15
|
+
CONFIG: 3,
|
|
16
|
+
BACKEND: 4,
|
|
17
|
+
ASSERTION: 5
|
|
18
|
+
};
|
|
19
|
+
var EXIT_CODE_TABLE = [
|
|
20
|
+
{ code: EXIT_CODES.SUCCESS, name: "SUCCESS", meaning: "the command completed and, where applicable, any assertion matched" },
|
|
21
|
+
{ code: EXIT_CODES.INTERNAL, name: "INTERNAL", meaning: "an unexpected error inside the CLI itself (not a usage or backend problem) \u2014 treat as a bug" },
|
|
22
|
+
{ code: EXIT_CODES.USAGE, name: "USAGE", meaning: "bad invocation: unknown verb/flag, a missing required argument, or a fixture the CLI cannot honour (unsupported format, capture-config mismatch)" },
|
|
23
|
+
{ code: EXIT_CODES.CONFIG, name: "CONFIG", meaning: "required provider configuration or API keys are missing" },
|
|
24
|
+
{ code: EXIT_CODES.BACKEND, name: "BACKEND", meaning: "the agent or backend failed while running the turn (provider/network error, timeout, pipeline error)" },
|
|
25
|
+
{ code: EXIT_CODES.ASSERTION, name: "ASSERTION", meaning: "a replayed fixture's transcript drifted from the expected transcript" }
|
|
26
|
+
];
|
|
27
|
+
var CliError = class extends Error {
|
|
28
|
+
exitCode;
|
|
29
|
+
details;
|
|
30
|
+
constructor(exitCode, message, details) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "CliError";
|
|
33
|
+
this.exitCode = exitCode;
|
|
34
|
+
this.details = details;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// src/agent-resolve.ts
|
|
39
|
+
async function resolveAgentFactory(spec, baseDir) {
|
|
40
|
+
const [modulePath, exportName] = spec.split("#");
|
|
41
|
+
if (!modulePath) {
|
|
42
|
+
throw new CliError(EXIT_CODES.USAGE, `--agent needs a module path, got: ${spec}`);
|
|
43
|
+
}
|
|
44
|
+
const abs = isAbsolute(modulePath) ? modulePath : resolve(baseDir, modulePath);
|
|
45
|
+
const url = pathToFileURL(abs).href;
|
|
46
|
+
let mod;
|
|
47
|
+
try {
|
|
48
|
+
mod = await import(url);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw new CliError(EXIT_CODES.USAGE, `could not load --agent module "${modulePath}" (resolved to ${abs}): ${err instanceof Error ? err.message : String(err)}`);
|
|
51
|
+
}
|
|
52
|
+
const picked = exportName ? mod[exportName] : mod["default"] ?? mod["createSession"];
|
|
53
|
+
if (typeof picked !== "function") {
|
|
54
|
+
const available = Object.keys(mod).filter((k) => typeof mod[k] === "function");
|
|
55
|
+
throw new CliError(
|
|
56
|
+
EXIT_CODES.USAGE,
|
|
57
|
+
`${abs} has no callable export ${exportName ? `"${exportName}"` : "(default or createSession)"}. Callable exports: ${available.length > 0 ? available.join(", ") : "(none)"}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const factory = picked;
|
|
61
|
+
const label = `${modulePath}${exportName ? `#${exportName}` : ""}`;
|
|
62
|
+
return {
|
|
63
|
+
label,
|
|
64
|
+
factory: async () => {
|
|
65
|
+
try {
|
|
66
|
+
return await factory();
|
|
67
|
+
} catch (err) {
|
|
68
|
+
throw new CliError(
|
|
69
|
+
EXIT_CODES.CONFIG,
|
|
70
|
+
`--agent ${label} failed to construct a session: ${err instanceof Error ? err.message : String(err)}`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/version.ts
|
|
78
|
+
import { createRequire } from "node:module";
|
|
79
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
80
|
+
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
81
|
+
|
|
82
|
+
// package.json
|
|
83
|
+
var package_default = {
|
|
84
|
+
name: "@kuralle-syrinx/cli",
|
|
85
|
+
version: "4.3.0",
|
|
86
|
+
private: false,
|
|
87
|
+
description: "Agent-facing CLI for Syrinx \u2014 run a turn or a text exchange with deterministic, machine-readable (--json) output and exit codes. Not a console: no REPL, no chat loop, no microphone.",
|
|
88
|
+
keywords: [
|
|
89
|
+
"voice",
|
|
90
|
+
"voice-agent",
|
|
91
|
+
"speech",
|
|
92
|
+
"syrinx",
|
|
93
|
+
"cli",
|
|
94
|
+
"agent",
|
|
95
|
+
"testing"
|
|
96
|
+
],
|
|
97
|
+
license: "MIT",
|
|
98
|
+
homepage: "https://github.com/kuralle/syrinx#readme",
|
|
99
|
+
repository: {
|
|
100
|
+
type: "git",
|
|
101
|
+
url: "git+https://github.com/kuralle/syrinx.git",
|
|
102
|
+
directory: "packages/cli"
|
|
103
|
+
},
|
|
104
|
+
bugs: {
|
|
105
|
+
url: "https://github.com/kuralle/syrinx/issues"
|
|
106
|
+
},
|
|
107
|
+
type: "module",
|
|
108
|
+
bin: {
|
|
109
|
+
syrinx: "./dist/index.js"
|
|
110
|
+
},
|
|
111
|
+
files: [
|
|
112
|
+
"dist",
|
|
113
|
+
"src",
|
|
114
|
+
"README.md"
|
|
115
|
+
],
|
|
116
|
+
exports: {
|
|
117
|
+
"./turn-runner": "./src/turn-runner.ts"
|
|
118
|
+
},
|
|
119
|
+
scripts: {
|
|
120
|
+
build: "node scripts/build.mjs",
|
|
121
|
+
typecheck: "tsc --noEmit",
|
|
122
|
+
test: "vitest run"
|
|
123
|
+
},
|
|
124
|
+
dependencies: {
|
|
125
|
+
"@kuralle-syrinx/core": "workspace:*",
|
|
126
|
+
wavefile: "^11.0.0"
|
|
127
|
+
},
|
|
128
|
+
devDependencies: {
|
|
129
|
+
"@kuralle-syrinx/test": "workspace:*",
|
|
130
|
+
"@types/node": "^22.0.0",
|
|
131
|
+
esbuild: "^0.28.1",
|
|
132
|
+
typescript: "^5.7.0",
|
|
133
|
+
vitest: "^3.2.6"
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// src/version.ts
|
|
138
|
+
var CLI_VERSION = package_default.version;
|
|
139
|
+
var CORE_PACKAGE_NAME = "@kuralle-syrinx/core";
|
|
140
|
+
function majorOf(version) {
|
|
141
|
+
return version.split(".")[0] ?? version;
|
|
142
|
+
}
|
|
143
|
+
function resolveInstalledPackageVersion(pkgName, fromDir) {
|
|
144
|
+
const anchor = join(resolvePath(fromDir), "package.json");
|
|
145
|
+
const require3 = createRequire(anchor);
|
|
146
|
+
let entryPath;
|
|
147
|
+
try {
|
|
148
|
+
entryPath = require3.resolve(pkgName);
|
|
149
|
+
} catch {
|
|
150
|
+
return void 0;
|
|
151
|
+
}
|
|
152
|
+
let dir = dirname(entryPath);
|
|
153
|
+
for (let depth = 0; depth < 20; depth += 1) {
|
|
154
|
+
const candidate = join(dir, "package.json");
|
|
155
|
+
if (existsSync(candidate)) {
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse(readFileSync(candidate, "utf8"));
|
|
158
|
+
if (parsed.name === pkgName && typeof parsed.version === "string") {
|
|
159
|
+
return { version: parsed.version, packageJsonPath: candidate };
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const parent = dirname(dir);
|
|
165
|
+
if (parent === dir) break;
|
|
166
|
+
dir = parent;
|
|
167
|
+
}
|
|
168
|
+
return void 0;
|
|
169
|
+
}
|
|
170
|
+
function checkCoreVersionSkew(fromDir) {
|
|
171
|
+
const resolved = resolveInstalledPackageVersion(CORE_PACKAGE_NAME, fromDir);
|
|
172
|
+
if (!resolved) {
|
|
173
|
+
return { cliVersion: CLI_VERSION, coreVersion: void 0, coreResolved: false, mismatch: false };
|
|
174
|
+
}
|
|
175
|
+
const mismatch = majorOf(resolved.version) !== majorOf(CLI_VERSION);
|
|
176
|
+
return { cliVersion: CLI_VERSION, coreVersion: resolved.version, coreResolved: true, mismatch };
|
|
177
|
+
}
|
|
178
|
+
function warnOnVersionSkew(fromDir) {
|
|
179
|
+
const check = checkCoreVersionSkew(fromDir);
|
|
180
|
+
if (check.mismatch) {
|
|
181
|
+
console.error(
|
|
182
|
+
`[syrinx] warning: CLI version ${check.cliVersion} (major ${majorOf(check.cliVersion)}) does not match the installed ${CORE_PACKAGE_NAME} version ${String(check.coreVersion)} (major ${majorOf(check.coreVersion ?? "")}) in ${fromDir}. Behavior may differ from what this CLI version expects.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return check;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// src/doctor.ts
|
|
189
|
+
var WELL_KNOWN_PROVIDER_KEYS = ["DEEPGRAM_API_KEY", "OPENAI_API_KEY", "CARTESIA_API_KEY", "GEMINI_API_KEY", "ELEVENLABS_API_KEY", "GROK_API_KEY"];
|
|
190
|
+
async function runDoctor(opts = {}) {
|
|
191
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
192
|
+
const env = opts.env ?? process.env;
|
|
193
|
+
const skew = checkCoreVersionSkew(cwd);
|
|
194
|
+
const wellKnownProviderKeys = Object.fromEntries(
|
|
195
|
+
WELL_KNOWN_PROVIDER_KEYS.map((key) => [key, Boolean(env[key]?.trim())])
|
|
196
|
+
);
|
|
197
|
+
let agent = null;
|
|
198
|
+
if (opts.agentSpec) {
|
|
199
|
+
try {
|
|
200
|
+
const resolved = await resolveAgentFactory(opts.agentSpec, cwd);
|
|
201
|
+
agent = { spec: opts.agentSpec, resolved: true, label: resolved.label };
|
|
202
|
+
} catch (err) {
|
|
203
|
+
agent = {
|
|
204
|
+
spec: opts.agentSpec,
|
|
205
|
+
resolved: false,
|
|
206
|
+
error: err instanceof CliError ? err.message : err instanceof Error ? err.message : String(err)
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const summary = !skew.coreResolved ? `${CORE_PACKAGE_NAME} is not installed in this project` : agent ? agent.resolved ? `agent resolved: ${agent.label}` : `agent could not be resolved: ${agent.error}` : "pass --agent <module>#<export> to check whether a specific agent resolves; the provider keys above are informational only \u2014 this CLI does not require any specific set";
|
|
211
|
+
return {
|
|
212
|
+
cliVersion: CLI_VERSION,
|
|
213
|
+
node: { version: opts.nodeVersion ?? process.version, platform: opts.platform ?? process.platform },
|
|
214
|
+
core: {
|
|
215
|
+
package: CORE_PACKAGE_NAME,
|
|
216
|
+
resolved: skew.coreResolved,
|
|
217
|
+
version: skew.coreVersion,
|
|
218
|
+
cliMajor: majorOf(CLI_VERSION),
|
|
219
|
+
coreMajor: skew.coreVersion ? majorOf(skew.coreVersion) : void 0,
|
|
220
|
+
majorMismatch: skew.mismatch
|
|
221
|
+
},
|
|
222
|
+
wellKnownProviderKeys,
|
|
223
|
+
agent,
|
|
224
|
+
summary
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/help.ts
|
|
229
|
+
var EXIT_CODE_LINES = EXIT_CODE_TABLE.map((row) => ` ${String(row.code)} ${row.name.padEnd(9)} ${row.meaning}`).join("\n");
|
|
230
|
+
var HELP_TEXT = `syrinx \u2014 the agent-facing CLI for a Syrinx voice agent.
|
|
231
|
+
|
|
232
|
+
Not a console: no REPL, no chat loop, no microphone. Every command is a single
|
|
233
|
+
deterministic invocation with a documented exit code, meant to be run by a
|
|
234
|
+
coding agent (or a script), not a human at a prompt.
|
|
235
|
+
|
|
236
|
+
This CLI brings no providers of its own \u2014 no Deepgram, no Cartesia, no OpenAI.
|
|
237
|
+
--agent points it at YOUR agent module; that module owns its own providers.
|
|
238
|
+
|
|
239
|
+
USAGE
|
|
240
|
+
syrinx <command> [options]
|
|
241
|
+
|
|
242
|
+
AGENT RESOLUTION (--agent, required for turn and text)
|
|
243
|
+
--agent <module>[#namedExport]
|
|
244
|
+
<module> is a path (resolved relative to cwd unless absolute) to your
|
|
245
|
+
own code; the export must be a zero-arg factory returning a
|
|
246
|
+
VoiceAgentSession (or a Promise of one) \u2014 exactly the contract
|
|
247
|
+
examples/02-hello-voice-headless/scripts/dev-server.ts's --agent flag
|
|
248
|
+
uses. Omit #export for a default export (or a "createSession" export).
|
|
249
|
+
Resolution failures (module not found, no such export, export not
|
|
250
|
+
callable) exit USAGE and name the callable exports that were found.
|
|
251
|
+
If the export IS callable but throws when invoked, that exits CONFIG \u2014
|
|
252
|
+
most plausibly the agent's own module is missing something it needs
|
|
253
|
+
(an env var, a key file, ...).
|
|
254
|
+
|
|
255
|
+
COMMANDS
|
|
256
|
+
turn --in <fixture.wav|fixture.json> --agent <module>#<export> [options]
|
|
257
|
+
Run one turn through the resolved agent and report the transcript, the
|
|
258
|
+
reply, and per-stage timings. Given a fixture.json sidecar (produced by
|
|
259
|
+
the Studio's "Save as fixture"), honours its recorded capture config
|
|
260
|
+
and, when the sidecar carries an expected transcript, asserts the
|
|
261
|
+
replay matches it.
|
|
262
|
+
--in <path> required. fixture.wav or fixture.json
|
|
263
|
+
--agent <module>#<export> required.
|
|
264
|
+
--session-dir <dir> where turn artifacts are written (default: a temp dir)
|
|
265
|
+
--json emit one parseable JSON object on stdout
|
|
266
|
+
|
|
267
|
+
text "<message>" --agent <module>#<export> [options]
|
|
268
|
+
Send a typed turn (no STT, no microphone) to the resolved agent and
|
|
269
|
+
report the reply.
|
|
270
|
+
--agent <module>#<export> required.
|
|
271
|
+
--json emit one parseable JSON object on stdout
|
|
272
|
+
|
|
273
|
+
doctor [--agent <module>#<export>] [--json]
|
|
274
|
+
Report what is configured: the Node runtime, whether
|
|
275
|
+
@kuralle-syrinx/core is installed and its version, and \u2014 informational
|
|
276
|
+
only \u2014 which well-known provider keys are present in the environment
|
|
277
|
+
(never their values). This CLI does not require any specific provider
|
|
278
|
+
key; pass --agent to check whether a SPECIFIC agent module resolves.
|
|
279
|
+
Always exits 0 \u2014 this command diagnoses, it does not assert.
|
|
280
|
+
|
|
281
|
+
--help, -h show this help
|
|
282
|
+
--version, -v print the CLI version
|
|
283
|
+
|
|
284
|
+
OUTPUT
|
|
285
|
+
--json is a first-class output mode: every command supports it and emits a
|
|
286
|
+
single parseable JSON object on stdout. Diagnostics (warnings, e.g. a
|
|
287
|
+
version-skew notice) always go to stderr, never stdout, so --json output
|
|
288
|
+
stays parseable. Without --json, commands print a short human-readable
|
|
289
|
+
summary to stdout and errors to stderr.
|
|
290
|
+
|
|
291
|
+
EXIT CODES
|
|
292
|
+
${EXIT_CODE_LINES}
|
|
293
|
+
`;
|
|
294
|
+
|
|
295
|
+
// src/text-turn.ts
|
|
296
|
+
import { randomUUID } from "node:crypto";
|
|
297
|
+
|
|
298
|
+
// ../core/src/pricing.ts
|
|
299
|
+
var DEFAULT_PRICE_CATALOG = {
|
|
300
|
+
source: "voice-prices@3 | deepgram.com/pricing PAYG Nova-3 streaming monolingual $0.0077/min \u2192 $0.000128333/s; deepgram.com/pricing Aura-2 TTS $0.030/1k chars \u2192 $30/1M; cartesia.ai/pricing + docs.cartesia.ai/pricing ~1 credit/char, PAYG ~$50/1M chars (cloudtalk.io cartesia-pricing 2026); openai tts-1 $15/1M chars (openai.com api pricing); gpt-4o-mini-tts text $0.60/1M (community/azure listed text input rate); openai GPT-4.1-mini $0.40/$1.60 per 1M in/out (developers.openai.com/api/docs/pricing; Azure GPT-4.1-mini-2025-04-14 Global); cloud.google.com/speech-to-text/pricing V2 standard recognition $0.016/min \u2192 $0.000266667/s (latest_long); ai.google.dev/pricing Gemini 2.5 Flash TTS preview text $0.50/1M chars (Gemini API TTS text input rate, 2026); elevenlabs.io/pricing/api Flash/Turbo TTS $0.05/1k chars \u2192 $50/1M; Multilingual v2/v3 $0.10/1k \u2192 $100/1M; Scribe v2 Realtime $0.39/hour \u2192 $0.000108333/s; grok/stt + grok/* TTS + epsilon/epsilon-tts intentionally ABSENT (no public list price \u2192 costOf unpriced, never silent $0)",
|
|
301
|
+
version: "3",
|
|
302
|
+
stt: {
|
|
303
|
+
"deepgram/nova-3": { usdPerAudioSecond: 77e-4 / 60 },
|
|
304
|
+
"deepgram/nova-2": { usdPerAudioSecond: 77e-4 / 60 },
|
|
305
|
+
"deepgram/flux-general-en": { usdPerAudioSecond: 77e-4 / 60 },
|
|
306
|
+
// Google Cloud Speech-to-Text V2 standard streaming recognition list rate.
|
|
307
|
+
"google/latest_long": { usdPerAudioSecond: 0.016 / 60 },
|
|
308
|
+
"google/latest_short": { usdPerAudioSecond: 0.016 / 60 },
|
|
309
|
+
"google/chirp_2": { usdPerAudioSecond: 0.016 / 60 },
|
|
310
|
+
// ElevenLabs Scribe v2 Realtime — public list $0.39/hour.
|
|
311
|
+
"elevenlabs/scribe_v2_realtime": { usdPerAudioSecond: 0.39 / 3600 },
|
|
312
|
+
"local/whisper": { usdPerAudioSecond: 0 }
|
|
313
|
+
// grok/stt: no public list price — omit so costOf → unpriced
|
|
314
|
+
},
|
|
315
|
+
llm: {
|
|
316
|
+
"openai/gpt-4.1-mini": {
|
|
317
|
+
usdPer1MInputTokens: 0.4,
|
|
318
|
+
usdPer1MOutputTokens: 1.6,
|
|
319
|
+
usdPer1MCachedInputTokens: 0.1
|
|
320
|
+
},
|
|
321
|
+
"local/llm": {
|
|
322
|
+
usdPer1MInputTokens: 0,
|
|
323
|
+
usdPer1MOutputTokens: 0
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
tts: {
|
|
327
|
+
"cartesia/sonic-3": { usdPer1MCharacters: 50 },
|
|
328
|
+
"openai/gpt-4o-mini-tts": { usdPer1MCharacters: 0.6 },
|
|
329
|
+
"openai/tts-1": { usdPer1MCharacters: 15 },
|
|
330
|
+
"deepgram/aura-2-thalia-en": { usdPer1MCharacters: 30 },
|
|
331
|
+
// Gemini TTS preview models — Gemini API TTS text input list rate.
|
|
332
|
+
"gemini/gemini-3.1-flash-tts-preview": { usdPer1MCharacters: 0.5 },
|
|
333
|
+
"gemini/gemini-2.5-flash-preview-tts": { usdPer1MCharacters: 0.5 },
|
|
334
|
+
"gemini/gemini-2.5-pro-preview-tts": { usdPer1MCharacters: 0.5 },
|
|
335
|
+
// ElevenLabs public list — Flash/Turbo $0.05/1k, Multilingual $0.10/1k.
|
|
336
|
+
"elevenlabs/eleven_flash_v2_5": { usdPer1MCharacters: 50 },
|
|
337
|
+
"elevenlabs/eleven_flash_v2": { usdPer1MCharacters: 50 },
|
|
338
|
+
"elevenlabs/eleven_turbo_v2_5": { usdPer1MCharacters: 50 },
|
|
339
|
+
"elevenlabs/eleven_turbo_v2": { usdPer1MCharacters: 50 },
|
|
340
|
+
"elevenlabs/eleven_multilingual_v2": { usdPer1MCharacters: 100 },
|
|
341
|
+
"local/tts": { usdPer1MCharacters: 0 }
|
|
342
|
+
// grok/* voice keys + epsilon/epsilon-tts: no public list — omit → unpriced
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// ../core/src/audio-envelope.ts
|
|
347
|
+
var SYRINX_AUDIO_ENVELOPE_MAGIC = new Uint8Array([83, 89, 82, 88, 65, 49, 10]);
|
|
348
|
+
|
|
349
|
+
// ../core/src/audio/g722.ts
|
|
350
|
+
var QMF_FWD = Object.freeze([
|
|
351
|
+
3,
|
|
352
|
+
-11,
|
|
353
|
+
12,
|
|
354
|
+
32,
|
|
355
|
+
-210,
|
|
356
|
+
951,
|
|
357
|
+
3876,
|
|
358
|
+
-805,
|
|
359
|
+
362,
|
|
360
|
+
-156,
|
|
361
|
+
53,
|
|
362
|
+
-11
|
|
363
|
+
]);
|
|
364
|
+
var QMF_REV = Object.freeze([
|
|
365
|
+
-11,
|
|
366
|
+
53,
|
|
367
|
+
-156,
|
|
368
|
+
362,
|
|
369
|
+
-805,
|
|
370
|
+
3876,
|
|
371
|
+
951,
|
|
372
|
+
-210,
|
|
373
|
+
32,
|
|
374
|
+
12,
|
|
375
|
+
-11,
|
|
376
|
+
3
|
|
377
|
+
]);
|
|
378
|
+
var QM2 = Object.freeze([-7408, -1616, 7408, 1616]);
|
|
379
|
+
var QM4 = Object.freeze([
|
|
380
|
+
0,
|
|
381
|
+
-20456,
|
|
382
|
+
-12896,
|
|
383
|
+
-8968,
|
|
384
|
+
-6288,
|
|
385
|
+
-4240,
|
|
386
|
+
-2584,
|
|
387
|
+
-1200,
|
|
388
|
+
20456,
|
|
389
|
+
12896,
|
|
390
|
+
8968,
|
|
391
|
+
6288,
|
|
392
|
+
4240,
|
|
393
|
+
2584,
|
|
394
|
+
1200,
|
|
395
|
+
0
|
|
396
|
+
]);
|
|
397
|
+
var QM6 = Object.freeze([
|
|
398
|
+
-136,
|
|
399
|
+
-136,
|
|
400
|
+
-136,
|
|
401
|
+
-136,
|
|
402
|
+
-24808,
|
|
403
|
+
-21904,
|
|
404
|
+
-19008,
|
|
405
|
+
-16704,
|
|
406
|
+
-14984,
|
|
407
|
+
-13512,
|
|
408
|
+
-12280,
|
|
409
|
+
-11192,
|
|
410
|
+
-10232,
|
|
411
|
+
-9360,
|
|
412
|
+
-8576,
|
|
413
|
+
-7856,
|
|
414
|
+
-7192,
|
|
415
|
+
-6576,
|
|
416
|
+
-6e3,
|
|
417
|
+
-5456,
|
|
418
|
+
-4944,
|
|
419
|
+
-4464,
|
|
420
|
+
-4008,
|
|
421
|
+
-3576,
|
|
422
|
+
-3168,
|
|
423
|
+
-2776,
|
|
424
|
+
-2400,
|
|
425
|
+
-2032,
|
|
426
|
+
-1688,
|
|
427
|
+
-1360,
|
|
428
|
+
-1040,
|
|
429
|
+
-728,
|
|
430
|
+
24808,
|
|
431
|
+
21904,
|
|
432
|
+
19008,
|
|
433
|
+
16704,
|
|
434
|
+
14984,
|
|
435
|
+
13512,
|
|
436
|
+
12280,
|
|
437
|
+
11192,
|
|
438
|
+
10232,
|
|
439
|
+
9360,
|
|
440
|
+
8576,
|
|
441
|
+
7856,
|
|
442
|
+
7192,
|
|
443
|
+
6576,
|
|
444
|
+
6e3,
|
|
445
|
+
5456,
|
|
446
|
+
4944,
|
|
447
|
+
4464,
|
|
448
|
+
4008,
|
|
449
|
+
3576,
|
|
450
|
+
3168,
|
|
451
|
+
2776,
|
|
452
|
+
2400,
|
|
453
|
+
2032,
|
|
454
|
+
1688,
|
|
455
|
+
1360,
|
|
456
|
+
1040,
|
|
457
|
+
728,
|
|
458
|
+
432,
|
|
459
|
+
136,
|
|
460
|
+
-432,
|
|
461
|
+
-136
|
|
462
|
+
]);
|
|
463
|
+
var Q6 = Object.freeze([
|
|
464
|
+
0,
|
|
465
|
+
35,
|
|
466
|
+
72,
|
|
467
|
+
110,
|
|
468
|
+
150,
|
|
469
|
+
190,
|
|
470
|
+
233,
|
|
471
|
+
276,
|
|
472
|
+
323,
|
|
473
|
+
370,
|
|
474
|
+
422,
|
|
475
|
+
473,
|
|
476
|
+
530,
|
|
477
|
+
587,
|
|
478
|
+
650,
|
|
479
|
+
714,
|
|
480
|
+
786,
|
|
481
|
+
858,
|
|
482
|
+
940,
|
|
483
|
+
1023,
|
|
484
|
+
1121,
|
|
485
|
+
1219,
|
|
486
|
+
1339,
|
|
487
|
+
1458,
|
|
488
|
+
1612,
|
|
489
|
+
1765,
|
|
490
|
+
1980,
|
|
491
|
+
2195,
|
|
492
|
+
2557,
|
|
493
|
+
2919,
|
|
494
|
+
0,
|
|
495
|
+
0
|
|
496
|
+
]);
|
|
497
|
+
var ILB = Object.freeze([
|
|
498
|
+
2048,
|
|
499
|
+
2093,
|
|
500
|
+
2139,
|
|
501
|
+
2186,
|
|
502
|
+
2233,
|
|
503
|
+
2282,
|
|
504
|
+
2332,
|
|
505
|
+
2383,
|
|
506
|
+
2435,
|
|
507
|
+
2489,
|
|
508
|
+
2543,
|
|
509
|
+
2599,
|
|
510
|
+
2656,
|
|
511
|
+
2714,
|
|
512
|
+
2774,
|
|
513
|
+
2834,
|
|
514
|
+
2896,
|
|
515
|
+
2960,
|
|
516
|
+
3025,
|
|
517
|
+
3091,
|
|
518
|
+
3158,
|
|
519
|
+
3228,
|
|
520
|
+
3298,
|
|
521
|
+
3371,
|
|
522
|
+
3444,
|
|
523
|
+
3520,
|
|
524
|
+
3597,
|
|
525
|
+
3676,
|
|
526
|
+
3756,
|
|
527
|
+
3838,
|
|
528
|
+
3922,
|
|
529
|
+
4008
|
|
530
|
+
]);
|
|
531
|
+
var ILN = Object.freeze([0, 63, 62, 31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 0]);
|
|
532
|
+
var ILP = Object.freeze([0, 61, 60, 59, 58, 57, 56, 55, 54, 53, 52, 51, 50, 49, 48, 47, 46, 45, 44, 43, 42, 41, 40, 39, 38, 37, 36, 35, 34, 33, 32, 0]);
|
|
533
|
+
var IHN = Object.freeze([0, 1, 0]);
|
|
534
|
+
var IHP = Object.freeze([0, 3, 2]);
|
|
535
|
+
var WL = Object.freeze([-60, -30, 58, 172, 334, 538, 1198, 3042]);
|
|
536
|
+
var RL42 = Object.freeze([0, 7, 6, 5, 4, 3, 2, 1, 7, 6, 5, 4, 3, 2, 1, 0]);
|
|
537
|
+
var WH = Object.freeze([0, -214, 798]);
|
|
538
|
+
var RH2 = Object.freeze([2, 1, 2, 1]);
|
|
539
|
+
|
|
540
|
+
// src/text-turn.ts
|
|
541
|
+
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
542
|
+
async function resolveSession(session) {
|
|
543
|
+
return typeof session === "function" ? session() : session;
|
|
544
|
+
}
|
|
545
|
+
async function driveText(opts) {
|
|
546
|
+
const session = await resolveSession(opts.session);
|
|
547
|
+
const contextId = randomUUID();
|
|
548
|
+
const t0 = performance.now();
|
|
549
|
+
let ttftMs = -1;
|
|
550
|
+
let reply = "";
|
|
551
|
+
let toolCalls = 0;
|
|
552
|
+
const on = (event, handler) => {
|
|
553
|
+
session.on(event, handler);
|
|
554
|
+
};
|
|
555
|
+
const finished = new Promise((resolveFinished, reject) => {
|
|
556
|
+
const timeout = setTimeout(() => {
|
|
557
|
+
reject(new CliError(EXIT_CODES.BACKEND, `agent did not finish within ${String(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)}ms`));
|
|
558
|
+
}, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
559
|
+
on("agent_text_delta", (event) => {
|
|
560
|
+
if (event.turnId !== contextId) return;
|
|
561
|
+
if (ttftMs < 0) ttftMs = performance.now() - t0;
|
|
562
|
+
reply += event.delta;
|
|
563
|
+
});
|
|
564
|
+
on("agent_tool_call", (event) => {
|
|
565
|
+
if (event.turnId !== contextId) return;
|
|
566
|
+
toolCalls += 1;
|
|
567
|
+
});
|
|
568
|
+
on("agent_finished", (event) => {
|
|
569
|
+
if (event.turnId !== contextId) return;
|
|
570
|
+
clearTimeout(timeout);
|
|
571
|
+
resolveFinished();
|
|
572
|
+
});
|
|
573
|
+
on("error", (event) => {
|
|
574
|
+
clearTimeout(timeout);
|
|
575
|
+
reject(new CliError(EXIT_CODES.BACKEND, `agent failed: ${event.message} (${event.stage}/${event.category})`));
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
void finished.catch(() => {
|
|
579
|
+
});
|
|
580
|
+
try {
|
|
581
|
+
await session.start();
|
|
582
|
+
session.bus.push(1 /* Main */, {
|
|
583
|
+
kind: "user.text_received",
|
|
584
|
+
contextId,
|
|
585
|
+
timestampMs: Date.now(),
|
|
586
|
+
text: opts.message
|
|
587
|
+
});
|
|
588
|
+
await finished;
|
|
589
|
+
} catch (err) {
|
|
590
|
+
if (err instanceof CliError) throw err;
|
|
591
|
+
throw new CliError(EXIT_CODES.BACKEND, `agent failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
592
|
+
} finally {
|
|
593
|
+
await session.close().catch(() => {
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
return {
|
|
597
|
+
reply,
|
|
598
|
+
ttftMs: ttftMs < 0 ? 0 : Math.round(ttftMs),
|
|
599
|
+
totalMs: Math.round(performance.now() - t0),
|
|
600
|
+
toolCalls
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// src/text-command.ts
|
|
605
|
+
async function runTextCommand(opts) {
|
|
606
|
+
const resolvedAgent = await resolveAgentFactory(opts.agentSpec, opts.cwd ?? process.cwd());
|
|
607
|
+
let result;
|
|
608
|
+
try {
|
|
609
|
+
result = await driveText({ session: resolvedAgent.factory, message: opts.message });
|
|
610
|
+
} catch (err) {
|
|
611
|
+
if (err instanceof CliError) throw err;
|
|
612
|
+
throw new CliError(EXIT_CODES.BACKEND, `text turn failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
613
|
+
}
|
|
614
|
+
return { ...result, agent: resolvedAgent.label };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// src/turn-command.ts
|
|
618
|
+
import { mkdtempSync } from "node:fs";
|
|
619
|
+
import { tmpdir } from "node:os";
|
|
620
|
+
import { extname, join as join4, resolve as resolve3 } from "node:path";
|
|
621
|
+
|
|
622
|
+
// src/fixture.ts
|
|
623
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
624
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
625
|
+
var FIXTURE_FORMAT = "syrinx.fixture.v1";
|
|
626
|
+
function loadFixture(sidecarPath) {
|
|
627
|
+
let raw;
|
|
628
|
+
try {
|
|
629
|
+
raw = readFileSync2(sidecarPath, "utf8");
|
|
630
|
+
} catch (err) {
|
|
631
|
+
throw new CliError(EXIT_CODES.USAGE, `fixture not found: ${sidecarPath}`, { cause: String(err) });
|
|
632
|
+
}
|
|
633
|
+
let sidecar;
|
|
634
|
+
try {
|
|
635
|
+
sidecar = JSON.parse(raw);
|
|
636
|
+
} catch (err) {
|
|
637
|
+
throw new CliError(EXIT_CODES.USAGE, `fixture is not valid JSON: ${sidecarPath}`, { cause: String(err) });
|
|
638
|
+
}
|
|
639
|
+
if (sidecar.format !== FIXTURE_FORMAT) {
|
|
640
|
+
throw new CliError(EXIT_CODES.USAGE, `unsupported fixture format "${sidecar.format}" (expected "${FIXTURE_FORMAT}")`);
|
|
641
|
+
}
|
|
642
|
+
if (sidecar.audio.sampleRateHz !== 16e3 || sidecar.audio.channels !== 1) {
|
|
643
|
+
throw new CliError(
|
|
644
|
+
EXIT_CODES.USAGE,
|
|
645
|
+
`this replay path needs mono 16 kHz audio; fixture is ${String(sidecar.audio.channels)}ch @ ${String(sidecar.audio.sampleRateHz)} Hz`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
const dir = dirname2(sidecarPath);
|
|
649
|
+
const sibling = join2(dir, "captured.wav");
|
|
650
|
+
let wavPath;
|
|
651
|
+
try {
|
|
652
|
+
readFileSync2(sibling);
|
|
653
|
+
wavPath = sibling;
|
|
654
|
+
} catch {
|
|
655
|
+
wavPath = join2(dir, sidecar.audioFile);
|
|
656
|
+
}
|
|
657
|
+
return { sidecarPath, sidecar, wavPath };
|
|
658
|
+
}
|
|
659
|
+
function normalizeTranscript(text) {
|
|
660
|
+
return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
|
|
661
|
+
}
|
|
662
|
+
function assertTranscript(expectedTranscript, actualTranscript) {
|
|
663
|
+
return {
|
|
664
|
+
expectedTranscript,
|
|
665
|
+
actualTranscript,
|
|
666
|
+
match: normalizeTranscript(expectedTranscript) === normalizeTranscript(actualTranscript)
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/turn-runner.ts
|
|
671
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
672
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
673
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
674
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
675
|
+
import { basename, join as join3, resolve as resolve2 } from "node:path";
|
|
676
|
+
var require2 = createRequire2(import.meta.url);
|
|
677
|
+
var { WaveFile } = require2("wavefile");
|
|
678
|
+
var SAMPLES_PER_FRAME = 320;
|
|
679
|
+
var DEFAULT_FIXTURE_PATH = "/Users/mithushancj/Documents/asyncdot/openscoped/voice-media-transport/research/agents/tests/test_realtime/hello_world.wav";
|
|
680
|
+
var DEFAULT_TTS_END_TIMEOUT_MS = 12e4;
|
|
681
|
+
function readPcm16Mono16kWav(filePath) {
|
|
682
|
+
const buf = readFileSync3(filePath);
|
|
683
|
+
const wav = new WaveFile(Buffer.from(buf));
|
|
684
|
+
const fmt = wav.fmt;
|
|
685
|
+
if (fmt.numChannels !== 1) throw new Error(`expected mono WAV, got ${String(fmt.numChannels)} channels`);
|
|
686
|
+
if (fmt.bitsPerSample !== 16 || fmt.audioFormat !== 1) throw new Error("expected 16-bit PCM WAV");
|
|
687
|
+
const raw = wav.getSamples(false, Int16Array);
|
|
688
|
+
const mono = Array.isArray(raw) ? raw[0] : raw;
|
|
689
|
+
if (mono === void 0 || !(mono instanceof Int16Array)) throw new Error("WAV has no mono channel samples");
|
|
690
|
+
return fmt.sampleRate === 16e3 ? mono : resamplePcm162(mono, fmt.sampleRate, 16e3);
|
|
691
|
+
}
|
|
692
|
+
function resolveInputWavPath(path) {
|
|
693
|
+
const resolved = resolve2(path);
|
|
694
|
+
try {
|
|
695
|
+
readFileSync3(resolved);
|
|
696
|
+
return resolved;
|
|
697
|
+
} catch {
|
|
698
|
+
if (basename(path) === "hello.wav") return DEFAULT_FIXTURE_PATH;
|
|
699
|
+
throw new Error(`input WAV not found: ${resolved}`);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
function resamplePcm162(samples, fromHz, toHz) {
|
|
703
|
+
if (fromHz <= 0 || toHz <= 0) throw new Error("invalid WAV sample rate");
|
|
704
|
+
const outLength = Math.max(1, Math.round(samples.length * toHz / fromHz));
|
|
705
|
+
const out = new Int16Array(outLength);
|
|
706
|
+
const ratio = fromHz / toHz;
|
|
707
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
708
|
+
const src = i * ratio;
|
|
709
|
+
const lo = Math.floor(src);
|
|
710
|
+
const hi = Math.min(samples.length - 1, lo + 1);
|
|
711
|
+
const frac = src - lo;
|
|
712
|
+
out[i] = Math.round(samples[lo] * (1 - frac) + samples[hi] * frac);
|
|
713
|
+
}
|
|
714
|
+
return out;
|
|
715
|
+
}
|
|
716
|
+
function pcmToBytes(samples) {
|
|
717
|
+
return new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength);
|
|
718
|
+
}
|
|
719
|
+
function sliceFramePcm(samples, offset) {
|
|
720
|
+
const end = Math.min(offset + SAMPLES_PER_FRAME, samples.length);
|
|
721
|
+
const frame = new Int16Array(SAMPLES_PER_FRAME);
|
|
722
|
+
if (end > offset) frame.set(samples.subarray(offset, end));
|
|
723
|
+
return frame;
|
|
724
|
+
}
|
|
725
|
+
function mergeBytes(chunks) {
|
|
726
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
727
|
+
const merged = new Uint8Array(total);
|
|
728
|
+
let offset = 0;
|
|
729
|
+
for (const chunk of chunks) {
|
|
730
|
+
merged.set(chunk, offset);
|
|
731
|
+
offset += chunk.byteLength;
|
|
732
|
+
}
|
|
733
|
+
return merged;
|
|
734
|
+
}
|
|
735
|
+
function writePcm16Wav(path, chunks, sampleRateHz) {
|
|
736
|
+
const bytes = mergeBytes(chunks);
|
|
737
|
+
const samples = new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2));
|
|
738
|
+
const wav = new WaveFile();
|
|
739
|
+
wav.fromScratch(1, sampleRateHz, "16", samples);
|
|
740
|
+
return writeFile(path, Buffer.from(wav.toBuffer()));
|
|
741
|
+
}
|
|
742
|
+
function eventLine(kind, data) {
|
|
743
|
+
return `${JSON.stringify({ tsMs: Date.now(), kind, ...data })}
|
|
744
|
+
`;
|
|
745
|
+
}
|
|
746
|
+
function sleep(ms) {
|
|
747
|
+
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
748
|
+
}
|
|
749
|
+
async function resolveSession2(session) {
|
|
750
|
+
return typeof session === "function" ? session() : session;
|
|
751
|
+
}
|
|
752
|
+
async function driveTurn(opts) {
|
|
753
|
+
const session = await resolveSession2(opts.session);
|
|
754
|
+
const sessionDir = resolve2(opts.sessionDir);
|
|
755
|
+
await mkdir(sessionDir, { recursive: true });
|
|
756
|
+
const pcm = opts.syntheticMono16kSamples !== void 0 ? Int16Array.from(opts.syntheticMono16kSamples) : readPcm16Mono16kWav(resolveInputWavPath(opts.inputWavPath));
|
|
757
|
+
const contextId = randomUUID2();
|
|
758
|
+
const inputChunks = [];
|
|
759
|
+
const outputChunks = [];
|
|
760
|
+
const eventLines = [];
|
|
761
|
+
const timeline = {
|
|
762
|
+
feedStartMs: 0,
|
|
763
|
+
speechEndMs: 0,
|
|
764
|
+
finalTranscriptMs: 0,
|
|
765
|
+
firstLlmDeltaMs: 0,
|
|
766
|
+
firstTtsAudioMs: 0,
|
|
767
|
+
ttsEndMs: 0
|
|
768
|
+
};
|
|
769
|
+
let finalTranscript = "";
|
|
770
|
+
let agentReply = "";
|
|
771
|
+
let toolCalls = 0;
|
|
772
|
+
const offRecordUser = session.bus.on("record.user_audio", (pkt) => {
|
|
773
|
+
inputChunks.push(pkt.audio);
|
|
774
|
+
});
|
|
775
|
+
const offTtsAudio = session.bus.on("tts.audio", (pkt) => {
|
|
776
|
+
if (timeline.firstTtsAudioMs === 0) timeline.firstTtsAudioMs = pkt.timestampMs;
|
|
777
|
+
outputChunks.push(pkt.audio);
|
|
778
|
+
});
|
|
779
|
+
const ttsEnd = new Promise((resolveEnd, reject) => {
|
|
780
|
+
const timeout = setTimeout(() => {
|
|
781
|
+
offTtsEnd();
|
|
782
|
+
reject(new Error("tts.end timeout"));
|
|
783
|
+
}, opts.ttsEndTimeoutMs ?? DEFAULT_TTS_END_TIMEOUT_MS);
|
|
784
|
+
const offTtsEnd = session.bus.on("tts.end", (pkt) => {
|
|
785
|
+
if (pkt.contextId !== contextId) return;
|
|
786
|
+
clearTimeout(timeout);
|
|
787
|
+
offTtsEnd();
|
|
788
|
+
timeline.ttsEndMs = pkt.timestampMs;
|
|
789
|
+
resolveEnd();
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
const on = (event, handler) => {
|
|
793
|
+
session.on(event, handler);
|
|
794
|
+
};
|
|
795
|
+
on("user_input_final", (event) => {
|
|
796
|
+
finalTranscript = event.text;
|
|
797
|
+
timeline.finalTranscriptMs = event.tsMs;
|
|
798
|
+
eventLines.push(eventLine("user_input_final", { turnId: event.turnId, text: event.text }));
|
|
799
|
+
});
|
|
800
|
+
on("agent_text_delta", (event) => {
|
|
801
|
+
if (timeline.firstLlmDeltaMs === 0) timeline.firstLlmDeltaMs = event.tsMs;
|
|
802
|
+
agentReply += event.delta;
|
|
803
|
+
eventLines.push(eventLine("agent_text_delta", { turnId: event.turnId, delta: event.delta }));
|
|
804
|
+
});
|
|
805
|
+
on("agent_tool_call", (event) => {
|
|
806
|
+
toolCalls += 1;
|
|
807
|
+
eventLines.push(eventLine("agent_tool_call", { turnId: event.turnId, name: event.name }));
|
|
808
|
+
});
|
|
809
|
+
on("agent_finished", (event) => {
|
|
810
|
+
eventLines.push(eventLine("agent_finished", { turnId: event.turnId }));
|
|
811
|
+
});
|
|
812
|
+
on("error", (event) => {
|
|
813
|
+
eventLines.push(eventLine("error", { stage: event.stage, category: event.category, message: event.message }));
|
|
814
|
+
});
|
|
815
|
+
await session.start();
|
|
816
|
+
session.bus.push(1 /* Main */, {
|
|
817
|
+
kind: "turn.change",
|
|
818
|
+
contextId,
|
|
819
|
+
previousContextId: "",
|
|
820
|
+
reason: "headless_turn_start",
|
|
821
|
+
timestampMs: Date.now()
|
|
822
|
+
});
|
|
823
|
+
let offset = 0;
|
|
824
|
+
while (offset < pcm.length) {
|
|
825
|
+
const frame = sliceFramePcm(pcm, offset);
|
|
826
|
+
const audio = pcmToBytes(frame);
|
|
827
|
+
if (timeline.feedStartMs === 0) timeline.feedStartMs = Date.now();
|
|
828
|
+
session.bus.push(1 /* Main */, {
|
|
829
|
+
kind: "user.audio_received",
|
|
830
|
+
contextId,
|
|
831
|
+
timestampMs: Date.now(),
|
|
832
|
+
audio
|
|
833
|
+
});
|
|
834
|
+
opts.onAudioFrame?.(contextId);
|
|
835
|
+
offset += SAMPLES_PER_FRAME;
|
|
836
|
+
if (opts.realtimePacing === true) await sleep(20);
|
|
837
|
+
}
|
|
838
|
+
timeline.speechEndMs = Date.now();
|
|
839
|
+
for (let pad = 0; pad < 40; pad += 1) {
|
|
840
|
+
const frame = new Int16Array(SAMPLES_PER_FRAME);
|
|
841
|
+
session.bus.push(1 /* Main */, {
|
|
842
|
+
kind: "user.audio_received",
|
|
843
|
+
contextId,
|
|
844
|
+
timestampMs: Date.now(),
|
|
845
|
+
audio: pcmToBytes(frame)
|
|
846
|
+
});
|
|
847
|
+
opts.onAudioFrame?.(contextId);
|
|
848
|
+
if (opts.realtimePacing === true) await sleep(20);
|
|
849
|
+
}
|
|
850
|
+
await opts.onAudioFed?.(contextId);
|
|
851
|
+
await ttsEnd;
|
|
852
|
+
offRecordUser();
|
|
853
|
+
offTtsAudio();
|
|
854
|
+
const metrics = {
|
|
855
|
+
turnId: contextId,
|
|
856
|
+
inputAudioMs: Math.round(pcm.length / 16e3 * 1e3),
|
|
857
|
+
speechEndToFinalTranscriptMs: timeline.speechEndMs > 0 && timeline.finalTranscriptMs > 0 ? Math.max(0, timeline.finalTranscriptMs - timeline.speechEndMs) : 0,
|
|
858
|
+
speechEndToFirstAudioMs: timeline.speechEndMs > 0 && timeline.firstTtsAudioMs > 0 ? Math.max(0, timeline.firstTtsAudioMs - timeline.speechEndMs) : 0,
|
|
859
|
+
endpointingMs: timeline.feedStartMs > 0 && timeline.finalTranscriptMs > 0 ? Math.max(0, timeline.finalTranscriptMs - timeline.feedStartMs) : 0,
|
|
860
|
+
llmTTFTMs: timeline.finalTranscriptMs > 0 && timeline.firstLlmDeltaMs > 0 ? Math.max(0, timeline.firstLlmDeltaMs - timeline.finalTranscriptMs) : 0,
|
|
861
|
+
ttsTTFBMs: timeline.firstLlmDeltaMs > 0 && timeline.firstTtsAudioMs > 0 ? Math.max(0, timeline.firstTtsAudioMs - timeline.firstLlmDeltaMs) : 0,
|
|
862
|
+
e2eLatencyMs: timeline.feedStartMs > 0 && timeline.firstTtsAudioMs > 0 ? Math.max(0, timeline.firstTtsAudioMs - timeline.feedStartMs) : 0,
|
|
863
|
+
agentTokens: agentReply.trim().length === 0 ? 0 : agentReply.trim().split(/\s+/).length,
|
|
864
|
+
playedMs: Math.round(mergeBytes(outputChunks).byteLength / 2 / 16e3 * 1e3),
|
|
865
|
+
truncated: false,
|
|
866
|
+
toolCalls
|
|
867
|
+
};
|
|
868
|
+
const inputWavPath = join3(sessionDir, "audio-in.wav");
|
|
869
|
+
const agentOutWavPath = join3(sessionDir, "audio-out.wav");
|
|
870
|
+
const eventsJsonlPath = join3(sessionDir, "events.jsonl");
|
|
871
|
+
const eventsJsonPath = join3(sessionDir, "events.json");
|
|
872
|
+
const transcriptJsonPath = join3(sessionDir, "transcript.json");
|
|
873
|
+
const metricsJsonPath = join3(sessionDir, "metrics.json");
|
|
874
|
+
const events = eventLines.map((line) => line.trim()).filter((line) => line.length > 0).map((line) => JSON.parse(line));
|
|
875
|
+
const qualityGate = {
|
|
876
|
+
passed: finalTranscript.trim().length > 0 && agentReply.trim().length > 0 && outputChunks.length > 0,
|
|
877
|
+
failures: [
|
|
878
|
+
...finalTranscript.trim().length === 0 ? ["missing final transcript"] : [],
|
|
879
|
+
...agentReply.trim().length === 0 ? ["missing agent reply"] : [],
|
|
880
|
+
...outputChunks.length === 0 ? ["missing TTS audio"] : []
|
|
881
|
+
]
|
|
882
|
+
};
|
|
883
|
+
await writePcm16Wav(inputWavPath, inputChunks, 16e3);
|
|
884
|
+
await writePcm16Wav(agentOutWavPath, outputChunks, 16e3);
|
|
885
|
+
await writeFile(eventsJsonlPath, eventLines.join(""), "utf8");
|
|
886
|
+
await writeFile(eventsJsonPath, `${JSON.stringify({ events, qualityGate }, null, 2)}
|
|
887
|
+
`, "utf8");
|
|
888
|
+
await writeFile(
|
|
889
|
+
transcriptJsonPath,
|
|
890
|
+
`${JSON.stringify({ finalTranscript, agentReply, turnCount: 1, metrics, qualityGate }, null, 2)}
|
|
891
|
+
`,
|
|
892
|
+
"utf8"
|
|
893
|
+
);
|
|
894
|
+
await writeFile(metricsJsonPath, `${JSON.stringify({ ...metrics, turnCount: 1, qualityGate }, null, 2)}
|
|
895
|
+
`, "utf8");
|
|
896
|
+
await session.close();
|
|
897
|
+
return {
|
|
898
|
+
sessionDir,
|
|
899
|
+
finalTranscript,
|
|
900
|
+
agentReply,
|
|
901
|
+
agentOutWavPath,
|
|
902
|
+
inputWavPath,
|
|
903
|
+
eventsJsonlPath,
|
|
904
|
+
eventsJsonPath,
|
|
905
|
+
transcriptJsonPath,
|
|
906
|
+
metricsJsonPath,
|
|
907
|
+
metrics,
|
|
908
|
+
durationMs: timeline.feedStartMs > 0 && timeline.ttsEndMs > 0 ? Math.max(0, timeline.ttsEndMs - timeline.feedStartMs) : 0
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// src/turn-command.ts
|
|
913
|
+
async function runTurnCommand(opts) {
|
|
914
|
+
const inputPath = resolve3(opts.inputPath);
|
|
915
|
+
const isFixtureSidecar = extname(inputPath).toLowerCase() === ".json";
|
|
916
|
+
const fixture = isFixtureSidecar ? loadFixture(inputPath) : void 0;
|
|
917
|
+
const wavPath = fixture ? fixture.wavPath : inputPath;
|
|
918
|
+
const resolvedAgent = await resolveAgentFactory(opts.agentSpec, opts.cwd ?? process.cwd());
|
|
919
|
+
const sessionDir = opts.sessionDir ?? mkdtempSync(join4(tmpdir(), "syrinx-turn-"));
|
|
920
|
+
let result;
|
|
921
|
+
try {
|
|
922
|
+
result = await driveTurn({
|
|
923
|
+
session: resolvedAgent.factory,
|
|
924
|
+
inputWavPath: wavPath,
|
|
925
|
+
sessionDir
|
|
926
|
+
});
|
|
927
|
+
} catch (err) {
|
|
928
|
+
if (err instanceof CliError) throw err;
|
|
929
|
+
throw new CliError(EXIT_CODES.BACKEND, `turn failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
930
|
+
}
|
|
931
|
+
let assertion = null;
|
|
932
|
+
if (fixture?.sidecar.expectedTranscript !== void 0) {
|
|
933
|
+
assertion = assertTranscript(fixture.sidecar.expectedTranscript, result.finalTranscript);
|
|
934
|
+
if (!assertion.match) {
|
|
935
|
+
throw new CliError(EXIT_CODES.ASSERTION, "replayed fixture's transcript drifted from the expected transcript", {
|
|
936
|
+
assertion
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return {
|
|
941
|
+
input: inputPath,
|
|
942
|
+
agent: resolvedAgent.label,
|
|
943
|
+
sessionDir: result.sessionDir,
|
|
944
|
+
transcript: result.finalTranscript,
|
|
945
|
+
reply: result.agentReply,
|
|
946
|
+
timings: result.metrics,
|
|
947
|
+
durationMs: result.durationMs,
|
|
948
|
+
assertion
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// src/cli.ts
|
|
953
|
+
var defaultIO = {
|
|
954
|
+
stdout: (line) => {
|
|
955
|
+
process.stdout.write(`${line}
|
|
956
|
+
`);
|
|
957
|
+
},
|
|
958
|
+
stderr: (line) => {
|
|
959
|
+
process.stderr.write(`${line}
|
|
960
|
+
`);
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
function exitCodeName(code) {
|
|
964
|
+
return EXIT_CODE_TABLE.find((row) => row.code === code)?.name ?? "UNKNOWN";
|
|
965
|
+
}
|
|
966
|
+
function looksLikeJsonRequested(args) {
|
|
967
|
+
return args.includes("--json");
|
|
968
|
+
}
|
|
969
|
+
function reportSuccess(io, json, verb, payload, prose) {
|
|
970
|
+
if (json) {
|
|
971
|
+
io.stdout(JSON.stringify({ ok: true, verb, ...payload }));
|
|
972
|
+
} else {
|
|
973
|
+
io.stdout(prose);
|
|
974
|
+
}
|
|
975
|
+
return EXIT_CODES.SUCCESS;
|
|
976
|
+
}
|
|
977
|
+
function reportError(io, json, verb, err) {
|
|
978
|
+
const cliErr = err instanceof CliError ? err : new CliError(EXIT_CODES.INTERNAL, err instanceof Error ? err.message : String(err));
|
|
979
|
+
if (cliErr.exitCode === EXIT_CODES.INTERNAL && err instanceof Error && err.stack) {
|
|
980
|
+
io.stderr(err.stack);
|
|
981
|
+
}
|
|
982
|
+
if (json) {
|
|
983
|
+
io.stdout(
|
|
984
|
+
JSON.stringify({
|
|
985
|
+
ok: false,
|
|
986
|
+
verb,
|
|
987
|
+
error: { code: exitCodeName(cliErr.exitCode), message: cliErr.message, ...cliErr.details ?? {} }
|
|
988
|
+
})
|
|
989
|
+
);
|
|
990
|
+
} else {
|
|
991
|
+
io.stderr(`error: ${cliErr.message}`);
|
|
992
|
+
}
|
|
993
|
+
return cliErr.exitCode;
|
|
994
|
+
}
|
|
995
|
+
async function handleTurn(args, io) {
|
|
996
|
+
let values;
|
|
997
|
+
try {
|
|
998
|
+
({ values } = parseArgs({
|
|
999
|
+
args,
|
|
1000
|
+
options: {
|
|
1001
|
+
in: { type: "string" },
|
|
1002
|
+
agent: { type: "string" },
|
|
1003
|
+
"session-dir": { type: "string" },
|
|
1004
|
+
json: { type: "boolean", default: false }
|
|
1005
|
+
},
|
|
1006
|
+
strict: true,
|
|
1007
|
+
allowPositionals: false
|
|
1008
|
+
}));
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
return reportError(io, looksLikeJsonRequested(args), "turn", new CliError(EXIT_CODES.USAGE, err instanceof Error ? err.message : String(err)));
|
|
1011
|
+
}
|
|
1012
|
+
const json = values.json === true;
|
|
1013
|
+
if (!values.in) {
|
|
1014
|
+
return reportError(io, json, "turn", new CliError(EXIT_CODES.USAGE, "turn requires --in <fixture.wav|fixture.json>"));
|
|
1015
|
+
}
|
|
1016
|
+
if (!values.agent) {
|
|
1017
|
+
return reportError(
|
|
1018
|
+
io,
|
|
1019
|
+
json,
|
|
1020
|
+
"turn",
|
|
1021
|
+
new CliError(EXIT_CODES.USAGE, "turn requires --agent <module>#<export> \u2014 the CLI brings no providers of its own (see --help)")
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
try {
|
|
1025
|
+
const result = await runTurnCommand({
|
|
1026
|
+
inputPath: values.in,
|
|
1027
|
+
agentSpec: values.agent,
|
|
1028
|
+
sessionDir: values["session-dir"]
|
|
1029
|
+
});
|
|
1030
|
+
const prose = [
|
|
1031
|
+
`agent: ${result.agent}`,
|
|
1032
|
+
`transcript: ${result.transcript || "(empty)"}`,
|
|
1033
|
+
`reply: ${result.reply || "(empty)"}`,
|
|
1034
|
+
`session: ${result.sessionDir}`,
|
|
1035
|
+
result.assertion ? `assertion: match (expected "${result.assertion.expectedTranscript}")` : "assertion: none"
|
|
1036
|
+
].join("\n");
|
|
1037
|
+
return reportSuccess(io, json, "turn", { ...result }, prose);
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
return reportError(io, json, "turn", err);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
async function handleText(args, io) {
|
|
1043
|
+
let values, positionals;
|
|
1044
|
+
try {
|
|
1045
|
+
({ values, positionals } = parseArgs({
|
|
1046
|
+
args,
|
|
1047
|
+
options: {
|
|
1048
|
+
agent: { type: "string" },
|
|
1049
|
+
json: { type: "boolean", default: false }
|
|
1050
|
+
},
|
|
1051
|
+
strict: true,
|
|
1052
|
+
allowPositionals: true
|
|
1053
|
+
}));
|
|
1054
|
+
} catch (err) {
|
|
1055
|
+
return reportError(io, looksLikeJsonRequested(args), "text", new CliError(EXIT_CODES.USAGE, err instanceof Error ? err.message : String(err)));
|
|
1056
|
+
}
|
|
1057
|
+
const json = values.json === true;
|
|
1058
|
+
const message = positionals[0];
|
|
1059
|
+
if (!message) {
|
|
1060
|
+
return reportError(io, json, "text", new CliError(EXIT_CODES.USAGE, 'text requires a message: syrinx text "<message>" --agent <module>#<export>'));
|
|
1061
|
+
}
|
|
1062
|
+
if (!values.agent) {
|
|
1063
|
+
return reportError(
|
|
1064
|
+
io,
|
|
1065
|
+
json,
|
|
1066
|
+
"text",
|
|
1067
|
+
new CliError(EXIT_CODES.USAGE, "text requires --agent <module>#<export> \u2014 the CLI brings no providers of its own (see --help)")
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
try {
|
|
1071
|
+
const result = await runTextCommand({ message, agentSpec: values.agent });
|
|
1072
|
+
const prose = `agent: ${result.agent}
|
|
1073
|
+
reply: ${result.reply || "(empty)"}
|
|
1074
|
+
ttft: ${String(result.ttftMs)}ms total: ${String(result.totalMs)}ms`;
|
|
1075
|
+
return reportSuccess(io, json, "text", { ...result }, prose);
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
return reportError(io, json, "text", err);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
async function handleDoctor(args, io) {
|
|
1081
|
+
let values;
|
|
1082
|
+
try {
|
|
1083
|
+
({ values } = parseArgs({
|
|
1084
|
+
args,
|
|
1085
|
+
options: { agent: { type: "string" }, json: { type: "boolean", default: false } },
|
|
1086
|
+
strict: true,
|
|
1087
|
+
allowPositionals: false
|
|
1088
|
+
}));
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
return reportError(io, looksLikeJsonRequested(args), "doctor", new CliError(EXIT_CODES.USAGE, err instanceof Error ? err.message : String(err)));
|
|
1091
|
+
}
|
|
1092
|
+
const json = values.json === true;
|
|
1093
|
+
const report = await runDoctor({ agentSpec: values.agent });
|
|
1094
|
+
const prose = [
|
|
1095
|
+
`syrinx cli ${report.cliVersion} node ${report.node.version} (${report.node.platform})`,
|
|
1096
|
+
`${report.core.package}: ${report.core.resolved ? `v${String(report.core.version)}` : "not installed"}${report.core.majorMismatch ? " (major mismatch with CLI)" : ""}`,
|
|
1097
|
+
"well-known provider keys (informational only):",
|
|
1098
|
+
...Object.entries(report.wellKnownProviderKeys).map(([key, present]) => ` ${key}: ${present ? "present" : "missing"}`),
|
|
1099
|
+
...report.agent ? [`agent (${report.agent.spec}): ${report.agent.resolved ? `resolved -> ${String(report.agent.label)}` : `NOT resolved (${String(report.agent.error)})`}`] : [],
|
|
1100
|
+
report.summary
|
|
1101
|
+
].join("\n");
|
|
1102
|
+
return reportSuccess(io, json, "doctor", { ...report }, prose);
|
|
1103
|
+
}
|
|
1104
|
+
async function main(argv, io = defaultIO) {
|
|
1105
|
+
try {
|
|
1106
|
+
warnOnVersionSkew(process.cwd());
|
|
1107
|
+
} catch {
|
|
1108
|
+
}
|
|
1109
|
+
const [verb, ...rest] = argv;
|
|
1110
|
+
if (verb === void 0 || verb === "--help" || verb === "-h") {
|
|
1111
|
+
io.stdout(HELP_TEXT);
|
|
1112
|
+
return EXIT_CODES.SUCCESS;
|
|
1113
|
+
}
|
|
1114
|
+
if (verb === "--version" || verb === "-v") {
|
|
1115
|
+
io.stdout(CLI_VERSION);
|
|
1116
|
+
return EXIT_CODES.SUCCESS;
|
|
1117
|
+
}
|
|
1118
|
+
switch (verb) {
|
|
1119
|
+
case "turn":
|
|
1120
|
+
return handleTurn(rest, io);
|
|
1121
|
+
case "text":
|
|
1122
|
+
return handleText(rest, io);
|
|
1123
|
+
case "doctor":
|
|
1124
|
+
return handleDoctor(rest, io);
|
|
1125
|
+
case "console":
|
|
1126
|
+
case "chat":
|
|
1127
|
+
case "listen":
|
|
1128
|
+
return reportError(
|
|
1129
|
+
io,
|
|
1130
|
+
looksLikeJsonRequested(rest),
|
|
1131
|
+
verb,
|
|
1132
|
+
new CliError(EXIT_CODES.USAGE, `"${verb}" is not a syrinx command \u2014 this CLI is non-interactive by design (see --help). Use the Studio for that.`)
|
|
1133
|
+
);
|
|
1134
|
+
default:
|
|
1135
|
+
return reportError(
|
|
1136
|
+
io,
|
|
1137
|
+
looksLikeJsonRequested(rest),
|
|
1138
|
+
verb,
|
|
1139
|
+
new CliError(EXIT_CODES.USAGE, `unknown command: ${verb} (see --help)`)
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// src/index.ts
|
|
1145
|
+
function fail(err) {
|
|
1146
|
+
if (err instanceof CliError) {
|
|
1147
|
+
console.error(err.message);
|
|
1148
|
+
return err.exitCode;
|
|
1149
|
+
}
|
|
1150
|
+
console.error(err instanceof Error ? err.stack ?? err.message : String(err));
|
|
1151
|
+
return EXIT_CODES.INTERNAL;
|
|
1152
|
+
}
|
|
1153
|
+
process.on("unhandledRejection", (reason) => {
|
|
1154
|
+
process.exitCode = fail(reason);
|
|
1155
|
+
});
|
|
1156
|
+
main(process.argv.slice(2)).then((code) => {
|
|
1157
|
+
process.exitCode = code;
|
|
1158
|
+
}).catch((err) => {
|
|
1159
|
+
process.exitCode = fail(err);
|
|
1160
|
+
});
|
|
1161
|
+
//# sourceMappingURL=index.js.map
|