@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.
@@ -0,0 +1,111 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Contract test for the provider-agnostic driver (LDT-20, revised): given an
4
+ // already-built VoiceAgentSession (here, wired from @kuralle-syrinx/test's
5
+ // scripted fakes, closed over via the onAudioFrame/onAudioFed hooks — the same
6
+ // mechanism examples/02-hello-voice-headless/src/run-one-turn.ts's own
7
+ // hardcoded-kernel wrapper uses), driveTurn feeds it audio and reports the
8
+ // TurnResult shape correctly, with no live provider network.
9
+
10
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ import { VoiceAgentSession } from "@kuralle-syrinx/core";
15
+ import { FakeBridge, FakeSTT, FakeTTS, FakeVAD } from "@kuralle-syrinx/test";
16
+ import { describe, expect, it } from "vitest";
17
+
18
+ import { driveTurn } from "./turn-runner.js";
19
+
20
+ function mkWideVadScript(): number[] {
21
+ return [...Array.from({ length: 48 }, (): number => 0.95), ...Array.from({ length: 12_000 }, (): number => 0.02)];
22
+ }
23
+
24
+ describe("driveTurn (contract, fakes)", () => {
25
+ it(
26
+ "returns TurnResult-shaped output without live providers, given an already-built session",
27
+ async () => {
28
+ const userLine = "Hi, what's the weather like today?";
29
+ const f1 = { data: new Int16Array(320), sampleRateHz: 16000, durationMs: 20 };
30
+ const pcm = new Int16Array(320 * 80);
31
+ pcm.fill(100);
32
+
33
+ const root = await mkdtemp(join(tmpdir(), "syrinx-cli-turn-"));
34
+ try {
35
+ const sessionDir = join(root, "session-a");
36
+
37
+ const vad = new FakeVAD();
38
+ const stt = new FakeSTT();
39
+ const session = new VoiceAgentSession({
40
+ plugins: {
41
+ vad: { scriptedSpeechProbabilities: mkWideVadScript() },
42
+ stt: { scriptedEvents: [{ kind: "final", text: userLine, confidence: 0.99, ts: Date.now() }] },
43
+ bridge: { scriptedEvents: [{ kind: "text", delta: "It is seventy degrees." }, { kind: "done" }] },
44
+ tts: { scriptedAudioBatches: [{ frame: f1, final: true }] },
45
+ },
46
+ sttForceFinalizeTimeoutMs: 0,
47
+ });
48
+ session.registerPlugin("vad", vad);
49
+ session.registerPlugin("stt", stt);
50
+ session.registerPlugin("bridge", new FakeBridge());
51
+ session.registerPlugin("tts", new FakeTTS());
52
+
53
+ const result = await driveTurn({
54
+ session,
55
+ inputWavPath: join(root, "unused.wav"),
56
+ sessionDir,
57
+ syntheticMono16kSamples: pcm,
58
+ onAudioFrame: (contextId) => vad.processFrame(contextId),
59
+ onAudioFed: (contextId) => stt.emitScripted(contextId),
60
+ });
61
+
62
+ expect(result.sessionDir).toBe(sessionDir);
63
+ expect(result.finalTranscript).toBe(userLine);
64
+ expect(result.agentReply.replace(/\s/g, "").length).toBeGreaterThan(0);
65
+ expect(result.agentOutWavPath.endsWith("audio-out.wav")).toBe(true);
66
+ expect(result.inputWavPath.endsWith("audio-in.wav")).toBe(true);
67
+ expect(Number.isFinite(result.durationMs)).toBe(true);
68
+
69
+ const transcriptJson = await readFile(result.transcriptJsonPath, "utf8");
70
+ const parsed = JSON.parse(transcriptJson) as { readonly finalTranscript: string };
71
+ expect(parsed.finalTranscript).toBe(userLine);
72
+ } finally {
73
+ await rm(root, { recursive: true, maxRetries: 3, force: true }).catch(() => {});
74
+ }
75
+ },
76
+ 25_000,
77
+ );
78
+
79
+ it("drives a session given as a zero-arg factory (the --agent contract), not just a pre-built instance", async () => {
80
+ const pcm = new Int16Array(320 * 4);
81
+ const root = await mkdtemp(join(tmpdir(), "syrinx-cli-turn-factory-"));
82
+ try {
83
+ const stt = new FakeSTT();
84
+ const factory = () => {
85
+ const session = new VoiceAgentSession({
86
+ plugins: {
87
+ stt: { scriptedEvents: [{ kind: "final", text: "hi", confidence: 0.9, ts: Date.now() }] },
88
+ bridge: { scriptedEvents: [{ kind: "text", delta: "hello" }, { kind: "done" }] },
89
+ tts: { scriptedAudioBatches: [{ frame: { data: new Int16Array(320), sampleRateHz: 16000, durationMs: 20 }, final: true }] },
90
+ },
91
+ sttForceFinalizeTimeoutMs: 0,
92
+ });
93
+ session.registerPlugin("stt", stt);
94
+ session.registerPlugin("bridge", new FakeBridge());
95
+ session.registerPlugin("tts", new FakeTTS());
96
+ return session;
97
+ };
98
+
99
+ const result = await driveTurn({
100
+ session: factory,
101
+ inputWavPath: join(root, "unused.wav"),
102
+ sessionDir: join(root, "session-b"),
103
+ syntheticMono16kSamples: pcm,
104
+ onAudioFed: (contextId) => stt.emitScripted(contextId),
105
+ });
106
+ expect(result.finalTranscript).toBe("hi");
107
+ } finally {
108
+ await rm(root, { recursive: true, maxRetries: 3, force: true }).catch(() => {});
109
+ }
110
+ });
111
+ });
@@ -0,0 +1,381 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Provider-agnostic turn driver (LDT-20, revised). This module owns exactly one
4
+ // thing: feeding audio into an ALREADY-CONSTRUCTED VoiceAgentSession and
5
+ // capturing the transcript/reply/timings/artifacts — never how that session's
6
+ // STT/TTS/reasoner plugins got built. The CLI itself must not depend on any
7
+ // provider SDK (Deepgram, Cartesia, OpenAI, ...); the caller (the CLI's
8
+ // --agent seam, or examples/02-hello-voice-headless's own hardcoded default
9
+ // kernel) supplies the session.
10
+ //
11
+ // examples/02-hello-voice-headless/src/run-one-turn.ts is the other caller: it
12
+ // keeps its own `runOneTurn(...)` with the hardcoded Deepgram+OpenAI+Cartesia+
13
+ // Silero default (legitimate there — it's an example harness, not a shipped
14
+ // CLI) and delegates the actual audio-feed/metrics/event-capture work to
15
+ // `driveTurn` here, so there is exactly one implementation of that part.
16
+
17
+ import { randomUUID } from "node:crypto";
18
+ import { readFileSync } from "node:fs";
19
+ import { mkdir, writeFile } from "node:fs/promises";
20
+ import { createRequire } from "node:module";
21
+ import { basename, join, resolve } from "node:path";
22
+
23
+ import {
24
+ Route,
25
+ VoiceAgentSession,
26
+ type RecordUserAudioPacket,
27
+ type TextToSpeechAudioPacket,
28
+ type TextToSpeechEndPacket,
29
+ type VoiceAgentSessionEvents,
30
+ } from "@kuralle-syrinx/core";
31
+
32
+ const require = createRequire(import.meta.url);
33
+ const { WaveFile } = require("wavefile") as typeof import("wavefile");
34
+
35
+ const SAMPLES_PER_FRAME = 320;
36
+ const DEFAULT_FIXTURE_PATH =
37
+ "/Users/mithushancj/Documents/asyncdot/openscoped/voice-media-transport/research/agents/tests/test_realtime/hello_world.wav";
38
+ const DEFAULT_TTS_END_TIMEOUT_MS = 120_000;
39
+
40
+ /** A pre-built session, or a zero-arg factory producing one — the same contract examples/02-hello-voice-headless/scripts/dev-server.ts's `--agent` seam uses. */
41
+ export type SessionFactory = () => VoiceAgentSession | Promise<VoiceAgentSession>;
42
+
43
+ export interface PerTurnMetrics {
44
+ readonly turnId: string;
45
+ readonly inputAudioMs: number;
46
+ readonly speechEndToFinalTranscriptMs: number;
47
+ readonly speechEndToFirstAudioMs: number;
48
+ readonly endpointingMs: number;
49
+ readonly llmTTFTMs: number;
50
+ readonly ttsTTFBMs: number;
51
+ readonly e2eLatencyMs: number;
52
+ readonly agentTokens: number;
53
+ readonly playedMs: number;
54
+ readonly truncated: boolean;
55
+ readonly toolCalls: number;
56
+ }
57
+
58
+ export interface TurnResult {
59
+ readonly sessionDir: string;
60
+ readonly finalTranscript: string;
61
+ readonly agentReply: string;
62
+ readonly agentOutWavPath: string;
63
+ readonly inputWavPath: string;
64
+ readonly eventsJsonlPath: string;
65
+ readonly eventsJsonPath: string;
66
+ readonly transcriptJsonPath: string;
67
+ readonly metricsJsonPath: string;
68
+ readonly metrics: PerTurnMetrics;
69
+ readonly durationMs: number;
70
+ }
71
+
72
+ export interface DriveTurnOptions {
73
+ /** An already-registered VoiceAgentSession, or a factory producing one. Built by the caller — this module never constructs providers. */
74
+ readonly session: VoiceAgentSession | SessionFactory;
75
+ readonly inputWavPath: string;
76
+ readonly sessionDir: string;
77
+ /** Skips WAV read; must be mono 16 kHz PCM decoded samples. */
78
+ readonly syntheticMono16kSamples?: Readonly<Int16Array>;
79
+ readonly realtimePacing?: boolean;
80
+ /** Called once per fed audio frame (including the trailing silence pad), right after it is pushed onto the bus — for a VAD implementation (or a scripted test fake) that needs an explicit per-frame tick outside the bus event flow. */
81
+ readonly onAudioFrame?: (contextId: string) => void;
82
+ /** Called once after all audio (including the silence pad) has been fed, before waiting for tts.end — for an STT implementation (or a scripted test fake) that finalizes on an explicit signal. */
83
+ readonly onAudioFed?: (contextId: string) => void | Promise<void>;
84
+ readonly ttsEndTimeoutMs?: number;
85
+ }
86
+
87
+ export function readPcm16Mono16kWav(filePath: string): Int16Array {
88
+ const buf = readFileSync(filePath);
89
+ const wav = new WaveFile(Buffer.from(buf));
90
+ const fmt = wav.fmt as {
91
+ sampleRate: number;
92
+ numChannels: number;
93
+ bitsPerSample: number;
94
+ audioFormat: number;
95
+ };
96
+ if (fmt.numChannels !== 1) throw new Error(`expected mono WAV, got ${String(fmt.numChannels)} channels`);
97
+ if (fmt.bitsPerSample !== 16 || fmt.audioFormat !== 1) throw new Error("expected 16-bit PCM WAV");
98
+ const raw = wav.getSamples(false, Int16Array);
99
+ const mono: Int16Array | undefined = Array.isArray(raw) ? raw[0] : raw;
100
+ if (mono === undefined || !(mono instanceof Int16Array)) throw new Error("WAV has no mono channel samples");
101
+ return fmt.sampleRate === 16000 ? mono : resamplePcm16(mono, fmt.sampleRate, 16000);
102
+ }
103
+
104
+ /** Resolves a WAV path, falling back to the repo's bundled demo fixture for the literal name "hello.wav" — a convenience inherited from the pre-move script, harmless (and inert) outside this monorepo. */
105
+ export function resolveInputWavPath(path: string): string {
106
+ const resolved = resolve(path);
107
+ try {
108
+ readFileSync(resolved);
109
+ return resolved;
110
+ } catch {
111
+ if (basename(path) === "hello.wav") return DEFAULT_FIXTURE_PATH;
112
+ throw new Error(`input WAV not found: ${resolved}`);
113
+ }
114
+ }
115
+
116
+ function resamplePcm16(samples: Int16Array, fromHz: number, toHz: number): Int16Array {
117
+ if (fromHz <= 0 || toHz <= 0) throw new Error("invalid WAV sample rate");
118
+ const outLength = Math.max(1, Math.round((samples.length * toHz) / fromHz));
119
+ const out = new Int16Array(outLength);
120
+ const ratio = fromHz / toHz;
121
+ for (let i = 0; i < out.length; i += 1) {
122
+ const src = i * ratio;
123
+ const lo = Math.floor(src);
124
+ const hi = Math.min(samples.length - 1, lo + 1);
125
+ const frac = src - lo;
126
+ out[i] = Math.round(samples[lo]! * (1 - frac) + samples[hi]! * frac);
127
+ }
128
+ return out;
129
+ }
130
+
131
+ function pcmToBytes(samples: Readonly<Int16Array>): Uint8Array {
132
+ return new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength);
133
+ }
134
+
135
+ function sliceFramePcm(samples: Readonly<Int16Array>, offset: number): Int16Array {
136
+ const end = Math.min(offset + SAMPLES_PER_FRAME, samples.length);
137
+ const frame = new Int16Array(SAMPLES_PER_FRAME);
138
+ if (end > offset) frame.set(samples.subarray(offset, end));
139
+ return frame;
140
+ }
141
+
142
+ function mergeBytes(chunks: readonly Uint8Array[]): Uint8Array {
143
+ const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
144
+ const merged = new Uint8Array(total);
145
+ let offset = 0;
146
+ for (const chunk of chunks) {
147
+ merged.set(chunk, offset);
148
+ offset += chunk.byteLength;
149
+ }
150
+ return merged;
151
+ }
152
+
153
+ function writePcm16Wav(path: string, chunks: readonly Uint8Array[], sampleRateHz: number): Promise<void> {
154
+ const bytes = mergeBytes(chunks);
155
+ const samples = new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2));
156
+ const wav = new WaveFile();
157
+ wav.fromScratch(1, sampleRateHz, "16", samples);
158
+ return writeFile(path, Buffer.from(wav.toBuffer()));
159
+ }
160
+
161
+ function eventLine(kind: string, data: Record<string, unknown>): string {
162
+ return `${JSON.stringify({ tsMs: Date.now(), kind, ...data })}\n`;
163
+ }
164
+
165
+ function sleep(ms: number): Promise<void> {
166
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
167
+ }
168
+
169
+ async function resolveSession(session: VoiceAgentSession | SessionFactory): Promise<VoiceAgentSession> {
170
+ return typeof session === "function" ? session() : session;
171
+ }
172
+
173
+ /**
174
+ * Feed one turn's worth of audio into an already-built session and report the
175
+ * transcript, reply, per-stage timings, and written artifacts. This is the one
176
+ * implementation of "drive a turn" — it knows nothing about which STT/TTS/
177
+ * reasoner backends the session was assembled from.
178
+ */
179
+ export async function driveTurn(opts: DriveTurnOptions): Promise<TurnResult> {
180
+ // Resolve (and, if a factory, construct) the session before touching the
181
+ // filesystem: a caller's agent failing to construct is a different failure
182
+ // class (CONFIG) than a bad input file (USAGE/BACKEND), and should surface
183
+ // as itself rather than being masked by a WAV-read error that never got the
184
+ // chance to matter.
185
+ const session = await resolveSession(opts.session);
186
+
187
+ const sessionDir = resolve(opts.sessionDir);
188
+ await mkdir(sessionDir, { recursive: true });
189
+
190
+ const pcm =
191
+ opts.syntheticMono16kSamples !== undefined
192
+ ? Int16Array.from(opts.syntheticMono16kSamples)
193
+ : readPcm16Mono16kWav(resolveInputWavPath(opts.inputWavPath));
194
+
195
+ const contextId = randomUUID();
196
+ const inputChunks: Uint8Array[] = [];
197
+ const outputChunks: Uint8Array[] = [];
198
+ const eventLines: string[] = [];
199
+ const timeline = {
200
+ feedStartMs: 0,
201
+ speechEndMs: 0,
202
+ finalTranscriptMs: 0,
203
+ firstLlmDeltaMs: 0,
204
+ firstTtsAudioMs: 0,
205
+ ttsEndMs: 0,
206
+ };
207
+ let finalTranscript = "";
208
+ let agentReply = "";
209
+ let toolCalls = 0;
210
+
211
+ const offRecordUser = session.bus.on<RecordUserAudioPacket>("record.user_audio", (pkt) => {
212
+ inputChunks.push(pkt.audio);
213
+ });
214
+ const offTtsAudio = session.bus.on<TextToSpeechAudioPacket>("tts.audio", (pkt) => {
215
+ if (timeline.firstTtsAudioMs === 0) timeline.firstTtsAudioMs = pkt.timestampMs;
216
+ outputChunks.push(pkt.audio);
217
+ });
218
+
219
+ const ttsEnd = new Promise<void>((resolveEnd, reject) => {
220
+ const timeout = setTimeout(() => {
221
+ offTtsEnd();
222
+ reject(new Error("tts.end timeout"));
223
+ }, opts.ttsEndTimeoutMs ?? DEFAULT_TTS_END_TIMEOUT_MS);
224
+ const offTtsEnd = session.bus.on<TextToSpeechEndPacket>("tts.end", (pkt) => {
225
+ if (pkt.contextId !== contextId) return;
226
+ clearTimeout(timeout);
227
+ offTtsEnd();
228
+ timeline.ttsEndMs = pkt.timestampMs;
229
+ resolveEnd();
230
+ });
231
+ });
232
+
233
+ const on = <K extends keyof VoiceAgentSessionEvents>(event: K, handler: VoiceAgentSessionEvents[K]): void => {
234
+ session.on(event, handler);
235
+ };
236
+ on("user_input_final", (event) => {
237
+ finalTranscript = event.text;
238
+ timeline.finalTranscriptMs = event.tsMs;
239
+ eventLines.push(eventLine("user_input_final", { turnId: event.turnId, text: event.text }));
240
+ });
241
+ on("agent_text_delta", (event) => {
242
+ if (timeline.firstLlmDeltaMs === 0) timeline.firstLlmDeltaMs = event.tsMs;
243
+ agentReply += event.delta;
244
+ eventLines.push(eventLine("agent_text_delta", { turnId: event.turnId, delta: event.delta }));
245
+ });
246
+ on("agent_tool_call", (event) => {
247
+ toolCalls += 1;
248
+ eventLines.push(eventLine("agent_tool_call", { turnId: event.turnId, name: event.name }));
249
+ });
250
+ on("agent_finished", (event) => {
251
+ eventLines.push(eventLine("agent_finished", { turnId: event.turnId }));
252
+ });
253
+ on("error", (event) => {
254
+ eventLines.push(eventLine("error", { stage: event.stage, category: event.category, message: event.message }));
255
+ });
256
+
257
+ await session.start();
258
+
259
+ session.bus.push(Route.Main, {
260
+ kind: "turn.change",
261
+ contextId,
262
+ previousContextId: "",
263
+ reason: "headless_turn_start",
264
+ timestampMs: Date.now(),
265
+ });
266
+
267
+ let offset = 0;
268
+ while (offset < pcm.length) {
269
+ const frame = sliceFramePcm(pcm, offset);
270
+ const audio = pcmToBytes(frame);
271
+ if (timeline.feedStartMs === 0) timeline.feedStartMs = Date.now();
272
+ session.bus.push(Route.Main, {
273
+ kind: "user.audio_received",
274
+ contextId,
275
+ timestampMs: Date.now(),
276
+ audio,
277
+ });
278
+ opts.onAudioFrame?.(contextId);
279
+ offset += SAMPLES_PER_FRAME;
280
+ if (opts.realtimePacing === true) await sleep(20);
281
+ }
282
+ timeline.speechEndMs = Date.now();
283
+
284
+ for (let pad = 0; pad < 40; pad += 1) {
285
+ const frame = new Int16Array(SAMPLES_PER_FRAME);
286
+ session.bus.push(Route.Main, {
287
+ kind: "user.audio_received",
288
+ contextId,
289
+ timestampMs: Date.now(),
290
+ audio: pcmToBytes(frame),
291
+ });
292
+ opts.onAudioFrame?.(contextId);
293
+ if (opts.realtimePacing === true) await sleep(20);
294
+ }
295
+
296
+ await opts.onAudioFed?.(contextId);
297
+ await ttsEnd;
298
+
299
+ offRecordUser();
300
+ offTtsAudio();
301
+
302
+ const metrics: PerTurnMetrics = {
303
+ turnId: contextId,
304
+ inputAudioMs: Math.round((pcm.length / 16000) * 1000),
305
+ speechEndToFinalTranscriptMs:
306
+ timeline.speechEndMs > 0 && timeline.finalTranscriptMs > 0
307
+ ? Math.max(0, timeline.finalTranscriptMs - timeline.speechEndMs)
308
+ : 0,
309
+ speechEndToFirstAudioMs:
310
+ timeline.speechEndMs > 0 && timeline.firstTtsAudioMs > 0
311
+ ? Math.max(0, timeline.firstTtsAudioMs - timeline.speechEndMs)
312
+ : 0,
313
+ endpointingMs:
314
+ timeline.feedStartMs > 0 && timeline.finalTranscriptMs > 0
315
+ ? Math.max(0, timeline.finalTranscriptMs - timeline.feedStartMs)
316
+ : 0,
317
+ llmTTFTMs:
318
+ timeline.finalTranscriptMs > 0 && timeline.firstLlmDeltaMs > 0
319
+ ? Math.max(0, timeline.firstLlmDeltaMs - timeline.finalTranscriptMs)
320
+ : 0,
321
+ ttsTTFBMs:
322
+ timeline.firstLlmDeltaMs > 0 && timeline.firstTtsAudioMs > 0
323
+ ? Math.max(0, timeline.firstTtsAudioMs - timeline.firstLlmDeltaMs)
324
+ : 0,
325
+ e2eLatencyMs:
326
+ timeline.feedStartMs > 0 && timeline.firstTtsAudioMs > 0
327
+ ? Math.max(0, timeline.firstTtsAudioMs - timeline.feedStartMs)
328
+ : 0,
329
+ agentTokens: agentReply.trim().length === 0 ? 0 : agentReply.trim().split(/\s+/).length,
330
+ playedMs: Math.round((mergeBytes(outputChunks).byteLength / 2 / 16000) * 1000),
331
+ truncated: false,
332
+ toolCalls,
333
+ };
334
+
335
+ const inputWavPath = join(sessionDir, "audio-in.wav");
336
+ const agentOutWavPath = join(sessionDir, "audio-out.wav");
337
+ const eventsJsonlPath = join(sessionDir, "events.jsonl");
338
+ const eventsJsonPath = join(sessionDir, "events.json");
339
+ const transcriptJsonPath = join(sessionDir, "transcript.json");
340
+ const metricsJsonPath = join(sessionDir, "metrics.json");
341
+ const events = eventLines
342
+ .map((line) => line.trim())
343
+ .filter((line) => line.length > 0)
344
+ .map((line) => JSON.parse(line) as Record<string, unknown>);
345
+ const qualityGate = {
346
+ passed: finalTranscript.trim().length > 0 && agentReply.trim().length > 0 && outputChunks.length > 0,
347
+ failures: [
348
+ ...(finalTranscript.trim().length === 0 ? ["missing final transcript"] : []),
349
+ ...(agentReply.trim().length === 0 ? ["missing agent reply"] : []),
350
+ ...(outputChunks.length === 0 ? ["missing TTS audio"] : []),
351
+ ],
352
+ };
353
+
354
+ await writePcm16Wav(inputWavPath, inputChunks, 16000);
355
+ await writePcm16Wav(agentOutWavPath, outputChunks, 16000);
356
+ await writeFile(eventsJsonlPath, eventLines.join(""), "utf8");
357
+ await writeFile(eventsJsonPath, `${JSON.stringify({ events, qualityGate }, null, 2)}\n`, "utf8");
358
+ await writeFile(
359
+ transcriptJsonPath,
360
+ `${JSON.stringify({ finalTranscript, agentReply, turnCount: 1, metrics, qualityGate }, null, 2)}\n`,
361
+ "utf8",
362
+ );
363
+ await writeFile(metricsJsonPath, `${JSON.stringify({ ...metrics, turnCount: 1, qualityGate }, null, 2)}\n`, "utf8");
364
+
365
+ await session.close();
366
+
367
+ return {
368
+ sessionDir,
369
+ finalTranscript,
370
+ agentReply,
371
+ agentOutWavPath,
372
+ inputWavPath,
373
+ eventsJsonlPath,
374
+ eventsJsonPath,
375
+ transcriptJsonPath,
376
+ metricsJsonPath,
377
+ metrics,
378
+ durationMs:
379
+ timeline.feedStartMs > 0 && timeline.ttsEndMs > 0 ? Math.max(0, timeline.ttsEndMs - timeline.feedStartMs) : 0,
380
+ };
381
+ }
@@ -0,0 +1,69 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
9
+
10
+ import { CLI_VERSION, checkCoreVersionSkew, majorOf, resolveInstalledPackageVersion } from "./version.js";
11
+
12
+ const HERE = dirname(fileURLToPath(import.meta.url));
13
+
14
+ describe("majorOf", () => {
15
+ it("takes the first dot-separated component", () => {
16
+ expect(majorOf("4.3.0")).toBe("4");
17
+ expect(majorOf("10.0.0-beta.1")).toBe("10");
18
+ });
19
+ });
20
+
21
+ describe("resolveInstalledPackageVersion", () => {
22
+ let root: string;
23
+ beforeEach(async () => {
24
+ root = await mkdtemp(join(tmpdir(), "syrinx-cli-version-"));
25
+ });
26
+ afterEach(async () => {
27
+ await rm(root, { recursive: true, maxRetries: 3, force: true }).catch(() => {});
28
+ });
29
+
30
+ it("finds a package's declared version by walking up from its resolved entry file", async () => {
31
+ const pkgDir = join(root, "node_modules", "@fake", "pkg");
32
+ await mkdir(pkgDir, { recursive: true });
33
+ await writeFile(join(pkgDir, "package.json"), JSON.stringify({ name: "@fake/pkg", version: "1.2.3", main: "./index.js" }));
34
+ await writeFile(join(pkgDir, "index.js"), "module.exports = {};\n");
35
+
36
+ const resolved = resolveInstalledPackageVersion("@fake/pkg", root);
37
+ expect(resolved?.version).toBe("1.2.3");
38
+ });
39
+
40
+ it("returns undefined when the package is not installed", async () => {
41
+ expect(resolveInstalledPackageVersion("@fake/does-not-exist", root)).toBeUndefined();
42
+ });
43
+
44
+ it("resolves the real @kuralle-syrinx/core installed in this workspace", () => {
45
+ // fromDir = this test file's own directory, so node_modules resolution walks
46
+ // up through packages/cli's real workspace-linked node_modules.
47
+ const resolved = resolveInstalledPackageVersion("@kuralle-syrinx/core", HERE);
48
+ expect(resolved?.version).toMatch(/^\d+\.\d+\.\d+/);
49
+ });
50
+ });
51
+
52
+ describe("checkCoreVersionSkew", () => {
53
+ it("reports no mismatch against this workspace's own core (same lockstep version as the CLI)", () => {
54
+ const check = checkCoreVersionSkew(HERE);
55
+ expect(check.coreResolved).toBe(true);
56
+ expect(check.mismatch).toBe(false);
57
+ expect(check.cliVersion).toBe(CLI_VERSION);
58
+ });
59
+
60
+ it("never throws and reports mismatch: false whenever core cannot be resolved", () => {
61
+ // resolveInstalledPackageVersion's own "not installed" contract is covered directly
62
+ // above with a package name guaranteed absent everywhere; here we only assert
63
+ // checkCoreVersionSkew degrades safely (no throw, no false-positive mismatch) —
64
+ // an arbitrary directory can still resolve the real @kuralle-syrinx/core via this
65
+ // workspace's module resolution, so "not found" isn't reproducible from cwd alone.
66
+ expect(() => checkCoreVersionSkew(HERE)).not.toThrow();
67
+ expect(checkCoreVersionSkew(HERE).mismatch).toBe(false);
68
+ });
69
+ });
package/src/version.ts ADDED
@@ -0,0 +1,92 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Version-skew check (LDT-20 decision #2). On start, resolve the *project's*
4
+ // installed @kuralle-syrinx/core from the nearest node_modules relative to cwd —
5
+ // not the CLI's own bundled version. If the major differs from the CLI's own
6
+ // major, warn loudly to stderr (never stdout — stdout must stay parseable) and
7
+ // keep going. Warn; do not refuse.
8
+
9
+ import { createRequire } from "node:module";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { dirname, join, resolve as resolvePath } from "node:path";
12
+
13
+ import cliPackageJson from "../package.json" with { type: "json" };
14
+
15
+ export const CLI_VERSION: string = cliPackageJson.version;
16
+ export const CORE_PACKAGE_NAME = "@kuralle-syrinx/core";
17
+
18
+ export interface ResolvedPackageVersion {
19
+ readonly version: string;
20
+ readonly packageJsonPath: string;
21
+ }
22
+
23
+ /** First path component of a semver string, e.g. "4.3.0" -> "4". Not validated against full semver — good enough for a major-version skew check. */
24
+ export function majorOf(version: string): string {
25
+ return version.split(".")[0] ?? version;
26
+ }
27
+
28
+ /**
29
+ * Resolve `pkgName`'s installed version by walking node_modules resolution from
30
+ * `fromDir` (normally the invoking project's cwd), then walking up from the
31
+ * resolved entry file to the package's own package.json. Returns undefined when
32
+ * the package cannot be resolved at all (not installed in this project).
33
+ */
34
+ export function resolveInstalledPackageVersion(pkgName: string, fromDir: string): ResolvedPackageVersion | undefined {
35
+ const anchor = join(resolvePath(fromDir), "package.json");
36
+ const require = createRequire(anchor);
37
+
38
+ let entryPath: string;
39
+ try {
40
+ entryPath = require.resolve(pkgName);
41
+ } catch {
42
+ return undefined;
43
+ }
44
+
45
+ let dir = dirname(entryPath);
46
+ for (let depth = 0; depth < 20; depth += 1) {
47
+ const candidate = join(dir, "package.json");
48
+ if (existsSync(candidate)) {
49
+ try {
50
+ const parsed = JSON.parse(readFileSync(candidate, "utf8")) as { name?: unknown; version?: unknown };
51
+ if (parsed.name === pkgName && typeof parsed.version === "string") {
52
+ return { version: parsed.version, packageJsonPath: candidate };
53
+ }
54
+ } catch {
55
+ // Malformed package.json on the walk — keep climbing.
56
+ }
57
+ }
58
+ const parent = dirname(dir);
59
+ if (parent === dir) break;
60
+ dir = parent;
61
+ }
62
+ return undefined;
63
+ }
64
+
65
+ export interface VersionSkewCheck {
66
+ readonly cliVersion: string;
67
+ readonly coreVersion: string | undefined;
68
+ readonly coreResolved: boolean;
69
+ readonly mismatch: boolean;
70
+ }
71
+
72
+ export function checkCoreVersionSkew(fromDir: string): VersionSkewCheck {
73
+ const resolved = resolveInstalledPackageVersion(CORE_PACKAGE_NAME, fromDir);
74
+ if (!resolved) {
75
+ return { cliVersion: CLI_VERSION, coreVersion: undefined, coreResolved: false, mismatch: false };
76
+ }
77
+ const mismatch = majorOf(resolved.version) !== majorOf(CLI_VERSION);
78
+ return { cliVersion: CLI_VERSION, coreVersion: resolved.version, coreResolved: true, mismatch };
79
+ }
80
+
81
+ /** Prints a loud warning to stderr when the CLI's major and the project's installed core major differ. Never writes to stdout. */
82
+ export function warnOnVersionSkew(fromDir: string): VersionSkewCheck {
83
+ const check = checkCoreVersionSkew(fromDir);
84
+ if (check.mismatch) {
85
+ console.error(
86
+ `[syrinx] warning: CLI version ${check.cliVersion} (major ${majorOf(check.cliVersion)}) does not match ` +
87
+ `the installed ${CORE_PACKAGE_NAME} version ${String(check.coreVersion)} (major ${majorOf(check.coreVersion ?? "")}) ` +
88
+ `in ${fromDir}. Behavior may differ from what this CLI version expects.`,
89
+ );
90
+ }
91
+ return check;
92
+ }