synthiaresearch 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export declare const DEFAULT_BASE_URL = "https://synthia-research--synthia-api-web.modal.run";
2
+ /** Audio input for overloaded surfaces: raw bytes or a path to an audio file. */
3
+ export type AudioInput = Uint8Array | string;
2
4
  /** One traced tool invocation made by the agent while answering a probe. */
3
5
  export interface ToolCall {
4
6
  name: string;
@@ -44,8 +46,11 @@ export declare class ToolSandbox {
44
46
  export interface TranscriptTurn {
45
47
  role: string;
46
48
  content: string;
49
+ /** Set on voiced turns (voice-enabled accounts); fetch via
50
+ * Rollouts.turnAudio. */
51
+ audio_url?: string | null;
47
52
  }
48
- export type RolloutAgent = (transcript: TranscriptTurn[], sandbox: ToolSandbox) => string | Promise<string>;
53
+ export type RolloutAgent = (transcript: TranscriptTurn[], sandbox: ToolSandbox) => string | AudioInput | Promise<string | AudioInput>;
49
54
  declare class Http {
50
55
  private baseUrl;
51
56
  headers: Record<string, string>;
@@ -55,6 +60,8 @@ declare class Http {
55
60
  constructor(baseUrl: string, headers: Record<string, string>);
56
61
  get(path: string, params?: Record<string, string | number>): Promise<any>;
57
62
  post(path: string, body?: unknown): Promise<any>;
63
+ /** Binary GET (audio artifacts). */
64
+ getBytes(path: string): Promise<Uint8Array>;
58
65
  /** Un-gated request returning the raw Response (used by the handshake). */
59
66
  raw(method: string, path: string, body?: unknown, params?: Record<string, string | number>): Promise<Response>;
60
67
  }
@@ -119,11 +126,18 @@ export declare class GenerationJob {
119
126
  export declare class Seeds {
120
127
  #private;
121
128
  constructor(http: Http);
122
- /** Upload seed material (documents, tool schemas, policies, traces...). */
129
+ /**
130
+ * Upload seed material (documents, tool schemas, policies, traces...).
131
+ *
132
+ * Overloaded on `content`: an object is ingested as-is (text pipeline);
133
+ * raw bytes or a path to an audio file (.wav/.mp3/...) is uploaded and
134
+ * transcribed server-side (voice-enabled accounts only) — the transcript
135
+ * becomes the seed content.
136
+ */
123
137
  ingest(opts: {
124
138
  kind: string;
125
139
  source: string;
126
- content: Record<string, unknown>;
140
+ content: Record<string, unknown> | AudioInput;
127
141
  version?: string;
128
142
  metadata?: Record<string, unknown>;
129
143
  }): Promise<any>;
@@ -171,6 +185,9 @@ export interface PrepareResult {
171
185
  reason: string;
172
186
  successRate: number | null;
173
187
  qualityCheckId: string | null;
188
+ /** Running render handles when prepare({voice: true}) pre-voiced the
189
+ * scenarios. */
190
+ voiceRenders?: VoiceRender[];
174
191
  }
175
192
  /**
176
193
  * An async evaluation of finished rollouts: per rollout, the server
@@ -192,9 +209,45 @@ export declare class QualityCheck {
192
209
  */
193
210
  rollouts(): Promise<any[]>;
194
211
  }
212
+ /**
213
+ * An async voice render: a scenario (LLM-authored script) or a rollout
214
+ * transcript, voiced with ElevenLabs — N takes spliced into one mixed WAV
215
+ * with per-turn provenance. Requires a voice-enabled customer config.
216
+ */
217
+ export declare class VoiceRender {
218
+ #private;
219
+ id: string;
220
+ status: string;
221
+ scenario_id: string | null;
222
+ rollout_id: string | null;
223
+ params: Record<string, unknown>;
224
+ duration_ms: number | null;
225
+ wpm: number | null;
226
+ provenance: any[] | null;
227
+ error: string | null;
228
+ constructor(data: any, http: Http);
229
+ /** Poll until the render finishes; verbose prints server telemetry
230
+ * (per-TTS-call latencies, take/mix progress). */
231
+ wait(opts?: WaitOptions): Promise<VoiceRender>;
232
+ /** The mixed conversation WAV. */
233
+ audio(): Promise<Uint8Array>;
234
+ /** Write the mixed conversation WAV to `path`; returns it. */
235
+ saveAudio(path: string): Promise<string>;
236
+ }
237
+ export interface VoiceOptions {
238
+ takes?: number;
239
+ engine?: "v2" | "v3";
240
+ annotate?: boolean;
241
+ phoneFx?: boolean;
242
+ roomTone?: boolean;
243
+ wpmRange?: [number, number];
244
+ voiceOverrides?: Record<string, string>;
245
+ }
195
246
  /**
196
247
  * One finished rollout: the conversation a scenario produced, plus every
197
248
  * tool call the agent made along the way (each tagged with its turn_idx).
249
+ * voice_render is attached (already running) when the account's config has
250
+ * voice.auto — await .wait()/.saveAudio() on it if you want the WAV.
198
251
  */
199
252
  export interface RolloutResult {
200
253
  rollout_id: string;
@@ -203,6 +256,7 @@ export interface RolloutResult {
203
256
  turns: number;
204
257
  transcript: TranscriptTurn[];
205
258
  tool_events: any[];
259
+ voice_render?: VoiceRender | null;
206
260
  }
207
261
  export interface RunOptions {
208
262
  maxTurns?: number;
@@ -210,12 +264,21 @@ export interface RunOptions {
210
264
  }
211
265
  export declare class Rollouts {
212
266
  #private;
213
- constructor(http: Http, sessionId?: string | null);
267
+ constructor(http: Http, sessionId?: string | null, voiceAuto?: boolean);
214
268
  /**
215
269
  * A stored rollout's full captured state: status, seed, transcript,
216
270
  * tool events, and sandbox.
217
271
  */
218
272
  get(rolloutId: string): Promise<any>;
273
+ /**
274
+ * Voice a finished rollout: the transcript maps to a script
275
+ * deterministically (words verbatim; `annotate` may add delivery tags
276
+ * only), then N takes are rendered and spliced into one mixed WAV.
277
+ * Requires a voice-enabled customer config (throws otherwise).
278
+ */
279
+ voice(rollout: RolloutResult | string, opts?: VoiceOptions): Promise<VoiceRender>;
280
+ /** One voiced turn's WAV (turns with an audio_url only). */
281
+ turnAudio(rolloutId: string, idx: number): Promise<Uint8Array>;
219
282
  /**
220
283
  * Play a dataset's scenarios against `agent` (most recent dataset when
221
284
  * none is given).
@@ -250,6 +313,12 @@ export interface PrepareOptions {
250
313
  minSuccessRate?: number;
251
314
  maxSuccessRate?: number;
252
315
  verbose?: boolean;
316
+ /** Additionally voice every scenario in the prepared dataset (an LLM
317
+ * authors each script, then a takes=1 render) — explicit opt-in because
318
+ * it spends per row; handles come back still-running on
319
+ * PrepareResult.voiceRenders. voice.auto accounts don't need this:
320
+ * their rollouts voice themselves. */
321
+ voice?: boolean;
253
322
  }
254
323
  /**
255
324
  * Client entry point.
@@ -270,11 +339,26 @@ export declare class Synthia {
270
339
  sessionName: string;
271
340
  sessionId: string | null;
272
341
  invocationId: string | null;
342
+ /** Voice mode, mirrored from the account's customer config by the
343
+ * session handshake: enabled unlocks the voice surfaces; auto makes
344
+ * rollouts voice themselves. Zero client-side flags — flipping a
345
+ * customer's config flips their SDK. */
346
+ voiceEnabled: boolean;
347
+ voiceAuto: boolean;
273
348
  seeds: Seeds;
274
349
  userModels: UserModels;
275
350
  datasets: Datasets;
276
351
  rollouts: Rollouts;
277
352
  constructor(options?: SynthiaOptions);
353
+ /**
354
+ * Voice one scenario (an LLM authors the full two-sided script) or one
355
+ * finished rollout (deterministic transcript transform). Exactly one
356
+ * source id. Requires a voice-enabled customer config.
357
+ */
358
+ voiceRender(opts: VoiceOptions & {
359
+ scenarioId?: string;
360
+ rolloutId?: string;
361
+ }): Promise<VoiceRender>;
278
362
  /**
279
363
  * Probe + generate only when needed; otherwise reuse the latest dataset.
280
364
  *
package/dist/client.js CHANGED
@@ -1,7 +1,41 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { readFileSync, writeFileSync } from "node:fs";
2
3
  import { basename, dirname, extname, resolve } from "node:path";
3
4
  export const DEFAULT_BASE_URL = "https://synthia-research--synthia-api-web.modal.run";
4
- const SDK_VERSION = "0.0.1";
5
+ const SDK_VERSION = "0.0.3"; // keep in lockstep with package.json
6
+ // File suffixes treated as audio where inputs are overloaded (seeds.ingest
7
+ // content, rollout agent replies).
8
+ const AUDIO_SUFFIXES = new Set([".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm"]);
9
+ /**
10
+ * base64 audio when `value` is audio — raw bytes, or a string path ending in
11
+ * an audio suffix — else null. The runtime half of the input overloads.
12
+ */
13
+ function asAudioB64(value) {
14
+ if (value instanceof Uint8Array)
15
+ return Buffer.from(value).toString("base64");
16
+ if (typeof value === "string" && AUDIO_SUFFIXES.has(extname(value).toLowerCase())) {
17
+ return readFileSync(value).toString("base64");
18
+ }
19
+ return null;
20
+ }
21
+ /**
22
+ * 403 on a voice surface means the account's config doesn't enable voice —
23
+ * rethrow something actionable instead of a bare status error.
24
+ */
25
+ function translateVoice403(e) {
26
+ if (e instanceof HttpError && e.status === 403) {
27
+ let detail = "voice is not enabled for this account";
28
+ try {
29
+ detail = JSON.parse(e.body).detail ?? detail;
30
+ }
31
+ catch {
32
+ /* keep default */
33
+ }
34
+ return new Error(`${detail} — voice is enabled per customer config; ask your Synthia ` +
35
+ "contact to turn it on for your organization");
36
+ }
37
+ return e;
38
+ }
5
39
  // Launcher argv[1] stems that don't identify a script (REPLs, runners).
6
40
  const INTERACTIVE_STEMS = new Set(["", "node", "npx", "tsx", "ts-node"]);
7
41
  /**
@@ -118,6 +152,14 @@ class Http {
118
152
  throw new HttpError(r.status, await r.text(), "POST", path);
119
153
  return r.json();
120
154
  }
155
+ /** Binary GET (audio artifacts). */
156
+ async getBytes(path) {
157
+ await this.ready;
158
+ const r = await this.raw("GET", path);
159
+ if (!r.ok)
160
+ throw new HttpError(r.status, await r.text(), "GET", path);
161
+ return new Uint8Array(await r.arrayBuffer());
162
+ }
121
163
  /** Un-gated request returning the raw Response (used by the handshake). */
122
164
  async raw(method, path, body, params) {
123
165
  const url = new URL(this.baseUrl + path);
@@ -291,8 +333,32 @@ export class Seeds {
291
333
  constructor(http) {
292
334
  this.#http = http;
293
335
  }
294
- /** Upload seed material (documents, tool schemas, policies, traces...). */
336
+ /**
337
+ * Upload seed material (documents, tool schemas, policies, traces...).
338
+ *
339
+ * Overloaded on `content`: an object is ingested as-is (text pipeline);
340
+ * raw bytes or a path to an audio file (.wav/.mp3/...) is uploaded and
341
+ * transcribed server-side (voice-enabled accounts only) — the transcript
342
+ * becomes the seed content.
343
+ */
295
344
  async ingest(opts) {
345
+ const audioB64 = asAudioB64(opts.content);
346
+ if (audioB64 !== null) {
347
+ const filename = typeof opts.content === "string" ? basename(opts.content) : null;
348
+ try {
349
+ return await this.#http.post("/v1/seeds", {
350
+ kind: opts.kind,
351
+ source: opts.source,
352
+ audio_b64: audioB64,
353
+ audio_filename: filename,
354
+ version: opts.version ?? "1",
355
+ metadata: opts.metadata ?? {},
356
+ });
357
+ }
358
+ catch (e) {
359
+ throw translateVoice403(e);
360
+ }
361
+ }
296
362
  return this.#http.post("/v1/seeds", {
297
363
  kind: opts.kind,
298
364
  source: opts.source,
@@ -425,12 +491,92 @@ export class QualityCheck {
425
491
  return body.data;
426
492
  }
427
493
  }
494
+ /**
495
+ * An async voice render: a scenario (LLM-authored script) or a rollout
496
+ * transcript, voiced with ElevenLabs — N takes spliced into one mixed WAV
497
+ * with per-turn provenance. Requires a voice-enabled customer config.
498
+ */
499
+ export class VoiceRender {
500
+ id;
501
+ status;
502
+ scenario_id;
503
+ rollout_id;
504
+ params;
505
+ duration_ms;
506
+ wpm;
507
+ provenance;
508
+ error;
509
+ #http;
510
+ constructor(data, http) {
511
+ this.id = data.id;
512
+ this.status = data.status;
513
+ this.scenario_id = data.scenario_id;
514
+ this.rollout_id = data.rollout_id;
515
+ this.params = data.params;
516
+ this.duration_ms = data.duration_ms;
517
+ this.wpm = data.wpm;
518
+ this.provenance = data.provenance;
519
+ this.error = data.error;
520
+ this.#http = http;
521
+ }
522
+ /** Poll until the render finishes; verbose prints server telemetry
523
+ * (per-TTS-call latencies, take/mix progress). */
524
+ async wait(opts = {}) {
525
+ const { pollInterval = 2, timeout = 1800, verbose = false } = opts;
526
+ const events = new EventStream(this.#http, `/v1/voice-renders/${this.id}/events`, verbose);
527
+ const deadline = Date.now() + timeout * 1000;
528
+ while (this.status === "running") {
529
+ if (Date.now() > deadline) {
530
+ throw new Error(`voice render ${this.id} still running after ${timeout}s`);
531
+ }
532
+ await sleep(pollInterval * 1000);
533
+ const data = await this.#http.get(`/v1/voice-renders/${this.id}`);
534
+ this.status = data.status;
535
+ this.params = data.params;
536
+ this.duration_ms = data.duration_ms;
537
+ this.wpm = data.wpm;
538
+ this.provenance = data.provenance;
539
+ this.error = data.error;
540
+ await events.pump();
541
+ }
542
+ await events.pump(); // catch events written after the final status poll
543
+ if (this.status !== "succeeded") {
544
+ throw new Error(`voice render ${this.id} failed: ${this.error}`);
545
+ }
546
+ return this;
547
+ }
548
+ /** The mixed conversation WAV. */
549
+ async audio() {
550
+ try {
551
+ return await this.#http.getBytes(`/v1/voice-renders/${this.id}/audio`);
552
+ }
553
+ catch (e) {
554
+ throw translateVoice403(e);
555
+ }
556
+ }
557
+ /** Write the mixed conversation WAV to `path`; returns it. */
558
+ async saveAudio(path) {
559
+ writeFileSync(path, await this.audio());
560
+ return path;
561
+ }
562
+ }
563
+ async function createVoiceRender(http, body) {
564
+ const clean = Object.fromEntries(Object.entries(body).filter(([, v]) => v !== undefined && v !== null));
565
+ try {
566
+ return new VoiceRender(await http.post("/v1/voice-renders", clean), http);
567
+ }
568
+ catch (e) {
569
+ throw translateVoice403(e);
570
+ }
571
+ }
428
572
  export class Rollouts {
429
573
  #http;
430
574
  #sessionId;
431
- constructor(http, sessionId = null) {
575
+ #voiceAuto;
576
+ constructor(http, sessionId = null, voiceAuto = false) {
432
577
  this.#http = http;
433
578
  this.#sessionId = sessionId;
579
+ this.#voiceAuto = voiceAuto;
434
580
  }
435
581
  /**
436
582
  * A stored rollout's full captured state: status, seed, transcript,
@@ -439,6 +585,34 @@ export class Rollouts {
439
585
  async get(rolloutId) {
440
586
  return this.#http.get(`/v1/rollouts/${rolloutId}`);
441
587
  }
588
+ /**
589
+ * Voice a finished rollout: the transcript maps to a script
590
+ * deterministically (words verbatim; `annotate` may add delivery tags
591
+ * only), then N takes are rendered and spliced into one mixed WAV.
592
+ * Requires a voice-enabled customer config (throws otherwise).
593
+ */
594
+ async voice(rollout, opts = {}) {
595
+ const rolloutId = typeof rollout === "string" ? rollout : rollout.rollout_id;
596
+ return createVoiceRender(this.#http, {
597
+ rollout_id: rolloutId,
598
+ takes: opts.takes ?? 1,
599
+ engine: opts.engine,
600
+ annotate: opts.annotate ?? false,
601
+ phone_fx: opts.phoneFx ?? false,
602
+ room_tone: opts.roomTone ?? false,
603
+ wpm_range: opts.wpmRange,
604
+ voice_overrides: opts.voiceOverrides,
605
+ });
606
+ }
607
+ /** One voiced turn's WAV (turns with an audio_url only). */
608
+ async turnAudio(rolloutId, idx) {
609
+ try {
610
+ return await this.#http.getBytes(`/v1/rollouts/${rolloutId}/turns/${idx}/audio`);
611
+ }
612
+ catch (e) {
613
+ throw translateVoice403(e);
614
+ }
615
+ }
442
616
  /**
443
617
  * Play a dataset's scenarios against `agent` (most recent dataset when
444
618
  * none is given).
@@ -491,6 +665,21 @@ export class Rollouts {
491
665
  }
492
666
  };
493
667
  await Promise.all(Array.from({ length: Math.min(concurrency, rows.length) }, worker));
668
+ if (this.#voiceAuto) {
669
+ // voice.auto accounts: every completed rollout gets a takes=1 mixed
670
+ // render, kicked here and attached still-running — audio is ready
671
+ // server-side whether or not anyone awaits it.
672
+ for (const result of results) {
673
+ if (result.status === "completed") {
674
+ try {
675
+ result.voice_render = await this.voice(result, { takes: 1 });
676
+ }
677
+ catch {
678
+ /* rendering is a bonus; results stand alone */
679
+ }
680
+ }
681
+ }
682
+ }
494
683
  return results;
495
684
  }
496
685
  /**
@@ -516,10 +705,23 @@ export class Rollouts {
516
705
  while (session.status === "running") {
517
706
  const sandbox = ToolSandbox.fromConfig(session.sandbox);
518
707
  const reply = await agent(session.transcript, sandbox);
519
- session = await this.#http.post(`/v1/rollouts/${session.id}/turns`, {
520
- reply,
521
- tool_calls: sandbox.events,
522
- });
708
+ const audioB64 = asAudioB64(reply);
709
+ const body = { tool_calls: sandbox.events };
710
+ if (audioB64 !== null) {
711
+ // The agent replied with audio — the server transcribes it
712
+ // (voice-enabled accounts) and the transcript drives the simulator.
713
+ body["reply"] = "";
714
+ body["audio_b64"] = audioB64;
715
+ }
716
+ else {
717
+ body["reply"] = reply;
718
+ }
719
+ try {
720
+ session = await this.#http.post(`/v1/rollouts/${session.id}/turns`, body);
721
+ }
722
+ catch (e) {
723
+ throw audioB64 !== null ? translateVoice403(e) : e;
724
+ }
523
725
  }
524
726
  return {
525
727
  rollout_id: session.id,
@@ -549,6 +751,12 @@ export class Synthia {
549
751
  sessionName;
550
752
  sessionId = null;
551
753
  invocationId = null;
754
+ /** Voice mode, mirrored from the account's customer config by the
755
+ * session handshake: enabled unlocks the voice surfaces; auto makes
756
+ * rollouts voice themselves. Zero client-side flags — flipping a
757
+ * customer's config flips their SDK. */
758
+ voiceEnabled = false;
759
+ voiceAuto = false;
552
760
  seeds;
553
761
  userModels;
554
762
  datasets;
@@ -611,9 +819,29 @@ export class Synthia {
611
819
  const data = (await r.json());
612
820
  this.sessionId = data.sdk_session_id;
613
821
  this.invocationId = data.sdk_invocation_id;
822
+ this.voiceEnabled = data.voice_enabled ?? false;
823
+ this.voiceAuto = data.voice_auto ?? false;
614
824
  this.#http.headers["X-Synthia-Session"] = this.sessionId;
615
825
  this.#http.headers["X-Synthia-Invocation"] = this.invocationId;
616
- this.rollouts = new Rollouts(this.#http, this.sessionId);
826
+ this.rollouts = new Rollouts(this.#http, this.sessionId, this.voiceAuto);
827
+ }
828
+ /**
829
+ * Voice one scenario (an LLM authors the full two-sided script) or one
830
+ * finished rollout (deterministic transcript transform). Exactly one
831
+ * source id. Requires a voice-enabled customer config.
832
+ */
833
+ async voiceRender(opts) {
834
+ return createVoiceRender(this.#http, {
835
+ scenario_id: opts.scenarioId,
836
+ rollout_id: opts.rolloutId,
837
+ takes: opts.takes ?? 1,
838
+ engine: opts.engine,
839
+ annotate: opts.annotate ?? false,
840
+ phone_fx: opts.phoneFx ?? false,
841
+ room_tone: opts.roomTone ?? false,
842
+ wpm_range: opts.wpmRange,
843
+ voice_overrides: opts.voiceOverrides,
844
+ });
617
845
  }
618
846
  /**
619
847
  * Probe + generate only when needed; otherwise reuse the latest dataset.
@@ -634,6 +862,16 @@ export class Synthia {
634
862
  * scripts/sessions never trigger regeneration here.
635
863
  */
636
864
  async prepare(agent, opts = {}) {
865
+ const result = await this.#prepare(agent, opts);
866
+ if (opts.voice) {
867
+ result.voiceRenders = [];
868
+ for (const row of await result.dataset.download()) {
869
+ result.voiceRenders.push(await this.voiceRender({ scenarioId: row.scenario_id, takes: 1 }));
870
+ }
871
+ }
872
+ return result;
873
+ }
874
+ async #prepare(agent, opts) {
637
875
  const { count = 20, maxTurns = 10, minSuccessRate = 0.6, maxSuccessRate = 0.9, verbose = false, } = opts;
638
876
  await this.#http.ready;
639
877
  const existing = await this.datasets.list(this.sessionId ?? undefined); // newest first
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { Synthia, ToolSandbox, Dataset, GenerationJob, ValidationRun, QualityCheck, Seeds, UserModels, Datasets, Rollouts, DEFAULT_BASE_URL, } from "./client.js";
2
- export type { Agent, AgentReply, PrepareOptions, PrepareResult, RolloutAgent, RolloutResult, RunOptions, SandboxConfig, SynthiaOptions, ToolCall, ToolEvent, TranscriptTurn, UserModel, WaitOptions, } from "./client.js";
1
+ export { Synthia, ToolSandbox, Dataset, GenerationJob, ValidationRun, QualityCheck, Seeds, UserModels, Datasets, Rollouts, VoiceRender, DEFAULT_BASE_URL, } from "./client.js";
2
+ export type { Agent, AgentReply, AudioInput, PrepareOptions, PrepareResult, RolloutAgent, RolloutResult, RunOptions, SandboxConfig, SynthiaOptions, ToolCall, ToolEvent, TranscriptTurn, UserModel, VoiceOptions, WaitOptions, } from "./client.js";
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { Synthia, ToolSandbox, Dataset, GenerationJob, ValidationRun, QualityCheck, Seeds, UserModels, Datasets, Rollouts, DEFAULT_BASE_URL, } from "./client.js";
1
+ export { Synthia, ToolSandbox, Dataset, GenerationJob, ValidationRun, QualityCheck, Seeds, UserModels, Datasets, Rollouts, VoiceRender, DEFAULT_BASE_URL, } from "./client.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthiaresearch",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "JavaScript/TypeScript SDK for the Synthia API",
5
5
  "license": "MIT",
6
6
  "type": "module",