@estuary-ai/sdk 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,6 +46,37 @@ client.sendText('What do you remember about me?');
46
46
  client.sendText('Just respond in text', true); // textOnly mode
47
47
  ```
48
48
 
49
+ ### Scripted Lines
50
+
51
+ Make the character speak exact, prewritten lines (straight to TTS, skipping the LLM):
52
+
53
+ ```typescript
54
+ client.sayLine('Welcome back, traveler.'); // TTS audio
55
+ client.sayLine('System: connection restored.', true); // text only, no audio
56
+ ```
57
+
58
+ For a sequence, use `playScript()` — it paces the lines so each finishes before the next is
59
+ sent. (`say_line` interrupts any in-progress speech, so firing several at once would make each
60
+ line cut off the previous one.)
61
+
62
+ ```typescript
63
+ const script = client.playScript(
64
+ ['Welcome to my shop, traveler!', 'I have wares, if you have coin.', 'Come back anytime.'],
65
+ { textOnly: false, lineGapMs: 250 },
66
+ );
67
+
68
+ client.on('scriptLineStarted', ({ index, text }) => console.log(`line ${index}: ${text}`));
69
+ await script.done; // resolves when all lines have been spoken
70
+
71
+ script.pause();
72
+ script.resume();
73
+ script.next(); // skip ahead
74
+ script.stop(); // halt + interrupt
75
+ ```
76
+
77
+ `sayLines(lines, opts)` is a convenience alias of `playScript(lines, opts)`. Lines may be plain
78
+ strings or per-line overrides: `{ text: '(a silent stage direction)', textOnly: true }`.
79
+
49
80
  ### Voice (WebSocket)
50
81
 
51
82
  ```typescript
@@ -182,6 +213,10 @@ client.on('interrupt', (data) => { /* response interrupted */ });
182
213
  client.on('characterAction', (action) => { /* parsed action from bot response */ });
183
214
  client.on('cameraCaptureRequest', (request) => { /* server requests a camera image */ });
184
215
 
216
+ // Scripted lines (playScript / sayLines)
217
+ client.on('scriptLineStarted', ({ index, text, messageId }) => { /* a scripted line began */ });
218
+ client.on('scriptComplete', ({ reason }) => { /* 'finished' | 'stopped' | 'disconnected' | 'interrupted' */ });
219
+
185
220
  // Voice
186
221
  client.on('voiceStarted', () => { /* voice session began */ });
187
222
  client.on('voiceStopped', () => { /* voice session ended */ });
package/dist/index.d.mts CHANGED
@@ -156,6 +156,46 @@ interface CharacterAction {
156
156
  /** Message ID of the bot response that contained this action */
157
157
  messageId: string;
158
158
  }
159
+ /** A scripted line: plain text (uses the script's default textOnly) or an explicit override. */
160
+ type ScriptLine = string | {
161
+ text: string;
162
+ textOnly?: boolean;
163
+ };
164
+ interface ScriptOptions {
165
+ /** Default for plain-string lines: false = TTS audio (default), true = text-only. */
166
+ textOnly?: boolean;
167
+ /** Pause inserted after each line completes, in ms (default 0). */
168
+ lineGapMs?: number;
169
+ /** Begin speaking immediately on creation (default true). If false, call play(). */
170
+ autoStart?: boolean;
171
+ /** Repeat from the first line after the last (default false). */
172
+ loop?: boolean;
173
+ /** Force-advance a line if no completion signal arrives within this many ms (default 30000). */
174
+ lineTimeoutMs?: number;
175
+ }
176
+ type ScriptEndReason = 'finished' | 'stopped' | 'disconnected' | 'interrupted';
177
+ type ScriptState = 'idle' | 'playing' | 'paused' | 'done';
178
+ interface ScriptLineStartedInfo {
179
+ index: number;
180
+ text: string;
181
+ messageId: string;
182
+ }
183
+ /** Handle returned by EstuaryClient.playScript() / sayLines(). */
184
+ interface ScriptController {
185
+ readonly length: number;
186
+ /** Index of the current / most-recently-started line (-1 before the first line starts). */
187
+ readonly index: number;
188
+ readonly state: ScriptState;
189
+ /** Resolves (never rejects) when the script ends, with the reason. Awaitable. */
190
+ readonly done: Promise<{
191
+ reason: ScriptEndReason;
192
+ }>;
193
+ play(): void;
194
+ pause(): void;
195
+ resume(): void;
196
+ next(): void;
197
+ stop(): void;
198
+ }
159
199
  type EstuaryEventMap = {
160
200
  connected: (session: SessionInfo) => void;
161
201
  disconnected: (reason: string) => void;
@@ -179,6 +219,10 @@ type EstuaryEventMap = {
179
219
  /** Bot audio level 0.0–1.0, emitted during playback for both transports. */
180
220
  botAudioLevel: (level: number) => void;
181
221
  memoryUpdated: (event: MemoryUpdatedEvent) => void;
222
+ scriptLineStarted: (info: ScriptLineStartedInfo) => void;
223
+ scriptComplete: (info: {
224
+ reason: ScriptEndReason;
225
+ }) => void;
182
226
  };
183
227
  interface VoiceManager {
184
228
  start(): Promise<void>;
@@ -360,6 +404,7 @@ declare class EstuaryClient extends TypedEventEmitter<EstuaryEventMap> {
360
404
  private _hasAutoInterrupted;
361
405
  private _autoInterruptGraceTimer;
362
406
  private _isLiveKitSpeaking;
407
+ private _activeScript;
363
408
  constructor(config: EstuaryConfig);
364
409
  /** Memory API client for querying memories, graphs, and facts */
365
410
  get memory(): MemoryClient;
@@ -385,6 +430,16 @@ declare class EstuaryClient extends TypedEventEmitter<EstuaryEventMap> {
385
430
  sendText(text: string, textOnly?: boolean): void;
386
431
  /** Script the character to say a specific prewritten line. Defaults to TTS enabled (textOnly=false). */
387
432
  sayLine(text: string, textOnly?: boolean): void;
433
+ /**
434
+ * Script a sequence of prewritten lines. Lines are paced so each finishes before the next
435
+ * is sent — required because say_line interrupts any in-progress response server-side, so
436
+ * unpaced lines would stomp each other. Returns a controller (play/pause/resume/next/stop +
437
+ * an awaitable `done`). Starting a new script stops any currently-active one.
438
+ */
439
+ playScript(lines: ScriptLine[], opts?: ScriptOptions): ScriptController;
440
+ /** Convenience alias of playScript() for fire-and-forget scripted sequences. */
441
+ sayLines(lines: ScriptLine[], opts?: ScriptOptions): ScriptController;
442
+ private createScriptHost;
388
443
  /** Interrupt the current bot response */
389
444
  interrupt(messageId?: string): void;
390
445
  /** Send a camera image for vision processing */
@@ -470,4 +525,4 @@ declare class CharacterClient {
470
525
  dispose(): void;
471
526
  }
472
527
 
473
- export { type BotResponse, type BotVoice, type CameraCaptureRequest, type CharacterAction, CharacterClient, type CharacterInfo, ConnectionState, type CoreFact, type CoreFactsResponse, ErrorCode, EstuaryClient, type EstuaryConfig, EstuaryError, type EstuaryEventMap, type InterruptData, type LiveKitTokenResponse, MemoryClient, type MemoryData, type MemoryGraphEdge, type MemoryGraphNode, type MemoryGraphOptions, type MemoryGraphResponse, type MemoryListOptions, type MemoryListResponse, type MemorySearchOptions, type MemorySearchResponse, type MemoryStatsResponse, type MemoryTimelineOptions, type MemoryTimelineResponse, type MemoryUpdatedEvent, type ParsedAction, type QuotaExceededData, type SessionCapabilities, type SessionInfo, type ShareOpenResponse, type SttResponse, type VoiceManager, type VoiceTransport, parseActions };
528
+ export { type BotResponse, type BotVoice, type CameraCaptureRequest, type CharacterAction, CharacterClient, type CharacterInfo, ConnectionState, type CoreFact, type CoreFactsResponse, ErrorCode, EstuaryClient, type EstuaryConfig, EstuaryError, type EstuaryEventMap, type InterruptData, type LiveKitTokenResponse, MemoryClient, type MemoryData, type MemoryGraphEdge, type MemoryGraphNode, type MemoryGraphOptions, type MemoryGraphResponse, type MemoryListOptions, type MemoryListResponse, type MemorySearchOptions, type MemorySearchResponse, type MemoryStatsResponse, type MemoryTimelineOptions, type MemoryTimelineResponse, type MemoryUpdatedEvent, type ParsedAction, type QuotaExceededData, type ScriptController, type ScriptEndReason, type ScriptLine, type ScriptLineStartedInfo, type ScriptOptions, type ScriptState, type SessionCapabilities, type SessionInfo, type ShareOpenResponse, type SttResponse, type VoiceManager, type VoiceTransport, parseActions };
package/dist/index.d.ts CHANGED
@@ -156,6 +156,46 @@ interface CharacterAction {
156
156
  /** Message ID of the bot response that contained this action */
157
157
  messageId: string;
158
158
  }
159
+ /** A scripted line: plain text (uses the script's default textOnly) or an explicit override. */
160
+ type ScriptLine = string | {
161
+ text: string;
162
+ textOnly?: boolean;
163
+ };
164
+ interface ScriptOptions {
165
+ /** Default for plain-string lines: false = TTS audio (default), true = text-only. */
166
+ textOnly?: boolean;
167
+ /** Pause inserted after each line completes, in ms (default 0). */
168
+ lineGapMs?: number;
169
+ /** Begin speaking immediately on creation (default true). If false, call play(). */
170
+ autoStart?: boolean;
171
+ /** Repeat from the first line after the last (default false). */
172
+ loop?: boolean;
173
+ /** Force-advance a line if no completion signal arrives within this many ms (default 30000). */
174
+ lineTimeoutMs?: number;
175
+ }
176
+ type ScriptEndReason = 'finished' | 'stopped' | 'disconnected' | 'interrupted';
177
+ type ScriptState = 'idle' | 'playing' | 'paused' | 'done';
178
+ interface ScriptLineStartedInfo {
179
+ index: number;
180
+ text: string;
181
+ messageId: string;
182
+ }
183
+ /** Handle returned by EstuaryClient.playScript() / sayLines(). */
184
+ interface ScriptController {
185
+ readonly length: number;
186
+ /** Index of the current / most-recently-started line (-1 before the first line starts). */
187
+ readonly index: number;
188
+ readonly state: ScriptState;
189
+ /** Resolves (never rejects) when the script ends, with the reason. Awaitable. */
190
+ readonly done: Promise<{
191
+ reason: ScriptEndReason;
192
+ }>;
193
+ play(): void;
194
+ pause(): void;
195
+ resume(): void;
196
+ next(): void;
197
+ stop(): void;
198
+ }
159
199
  type EstuaryEventMap = {
160
200
  connected: (session: SessionInfo) => void;
161
201
  disconnected: (reason: string) => void;
@@ -179,6 +219,10 @@ type EstuaryEventMap = {
179
219
  /** Bot audio level 0.0–1.0, emitted during playback for both transports. */
180
220
  botAudioLevel: (level: number) => void;
181
221
  memoryUpdated: (event: MemoryUpdatedEvent) => void;
222
+ scriptLineStarted: (info: ScriptLineStartedInfo) => void;
223
+ scriptComplete: (info: {
224
+ reason: ScriptEndReason;
225
+ }) => void;
182
226
  };
183
227
  interface VoiceManager {
184
228
  start(): Promise<void>;
@@ -360,6 +404,7 @@ declare class EstuaryClient extends TypedEventEmitter<EstuaryEventMap> {
360
404
  private _hasAutoInterrupted;
361
405
  private _autoInterruptGraceTimer;
362
406
  private _isLiveKitSpeaking;
407
+ private _activeScript;
363
408
  constructor(config: EstuaryConfig);
364
409
  /** Memory API client for querying memories, graphs, and facts */
365
410
  get memory(): MemoryClient;
@@ -385,6 +430,16 @@ declare class EstuaryClient extends TypedEventEmitter<EstuaryEventMap> {
385
430
  sendText(text: string, textOnly?: boolean): void;
386
431
  /** Script the character to say a specific prewritten line. Defaults to TTS enabled (textOnly=false). */
387
432
  sayLine(text: string, textOnly?: boolean): void;
433
+ /**
434
+ * Script a sequence of prewritten lines. Lines are paced so each finishes before the next
435
+ * is sent — required because say_line interrupts any in-progress response server-side, so
436
+ * unpaced lines would stomp each other. Returns a controller (play/pause/resume/next/stop +
437
+ * an awaitable `done`). Starting a new script stops any currently-active one.
438
+ */
439
+ playScript(lines: ScriptLine[], opts?: ScriptOptions): ScriptController;
440
+ /** Convenience alias of playScript() for fire-and-forget scripted sequences. */
441
+ sayLines(lines: ScriptLine[], opts?: ScriptOptions): ScriptController;
442
+ private createScriptHost;
388
443
  /** Interrupt the current bot response */
389
444
  interrupt(messageId?: string): void;
390
445
  /** Send a camera image for vision processing */
@@ -470,4 +525,4 @@ declare class CharacterClient {
470
525
  dispose(): void;
471
526
  }
472
527
 
473
- export { type BotResponse, type BotVoice, type CameraCaptureRequest, type CharacterAction, CharacterClient, type CharacterInfo, ConnectionState, type CoreFact, type CoreFactsResponse, ErrorCode, EstuaryClient, type EstuaryConfig, EstuaryError, type EstuaryEventMap, type InterruptData, type LiveKitTokenResponse, MemoryClient, type MemoryData, type MemoryGraphEdge, type MemoryGraphNode, type MemoryGraphOptions, type MemoryGraphResponse, type MemoryListOptions, type MemoryListResponse, type MemorySearchOptions, type MemorySearchResponse, type MemoryStatsResponse, type MemoryTimelineOptions, type MemoryTimelineResponse, type MemoryUpdatedEvent, type ParsedAction, type QuotaExceededData, type SessionCapabilities, type SessionInfo, type ShareOpenResponse, type SttResponse, type VoiceManager, type VoiceTransport, parseActions };
528
+ export { type BotResponse, type BotVoice, type CameraCaptureRequest, type CharacterAction, CharacterClient, type CharacterInfo, ConnectionState, type CoreFact, type CoreFactsResponse, ErrorCode, EstuaryClient, type EstuaryConfig, EstuaryError, type EstuaryEventMap, type InterruptData, type LiveKitTokenResponse, MemoryClient, type MemoryData, type MemoryGraphEdge, type MemoryGraphNode, type MemoryGraphOptions, type MemoryGraphResponse, type MemoryListOptions, type MemoryListResponse, type MemorySearchOptions, type MemorySearchResponse, type MemoryStatsResponse, type MemoryTimelineOptions, type MemoryTimelineResponse, type MemoryUpdatedEvent, type ParsedAction, type QuotaExceededData, type ScriptController, type ScriptEndReason, type ScriptLine, type ScriptLineStartedInfo, type ScriptOptions, type ScriptState, type SessionCapabilities, type SessionInfo, type ShareOpenResponse, type SttResponse, type VoiceManager, type VoiceTransport, parseActions };
package/dist/index.js CHANGED
@@ -9624,6 +9624,231 @@ function parseActions(text) {
9624
9624
  return parser.parse(text);
9625
9625
  }
9626
9626
 
9627
+ // src/scripting/script-player.ts
9628
+ var DEFAULT_LINE_TIMEOUT_MS = 3e4;
9629
+ var ScriptPlayer = class {
9630
+ host;
9631
+ lines;
9632
+ lineGapMs;
9633
+ loop;
9634
+ lineTimeoutMs;
9635
+ _index = -1;
9636
+ _state = "idle";
9637
+ currentMessageId = null;
9638
+ lineAcked = false;
9639
+ retiredIds = /* @__PURE__ */ new Set();
9640
+ selfInterruptPending = false;
9641
+ pauseRequested = false;
9642
+ // Number of stray bot_responses still expected from text-only lines that were superseded
9643
+ // (via next()/stop()) BEFORE they were acked. Their server messageId isn't known yet, so we
9644
+ // can't retire them by id — instead we swallow that many fresh responses by arrival order.
9645
+ pendingStaleResponses = 0;
9646
+ lineTimer = null;
9647
+ gapTimer = null;
9648
+ resolveDone;
9649
+ done;
9650
+ onBotResponse = (r) => this.handleBotResponse(r);
9651
+ onAudioComplete = (messageId) => this.handleAudioComplete(messageId);
9652
+ onInterrupt = (data) => this.handleExternalInterrupt(data);
9653
+ onDisconnected = () => this.finish("disconnected");
9654
+ constructor(host, lines, opts = {}) {
9655
+ this.host = host;
9656
+ const defaultTextOnly = opts.textOnly ?? false;
9657
+ this.lines = lines.map(
9658
+ (line) => typeof line === "string" ? { text: line, textOnly: defaultTextOnly } : { text: line.text, textOnly: line.textOnly ?? defaultTextOnly }
9659
+ );
9660
+ this.lineGapMs = Math.max(0, opts.lineGapMs ?? 0);
9661
+ this.loop = opts.loop ?? false;
9662
+ this.lineTimeoutMs = opts.lineTimeoutMs ?? DEFAULT_LINE_TIMEOUT_MS;
9663
+ this.done = new Promise((resolve) => {
9664
+ this.resolveDone = resolve;
9665
+ });
9666
+ this.host.on("botResponse", this.onBotResponse);
9667
+ this.host.on("audioPlaybackComplete", this.onAudioComplete);
9668
+ this.host.on("interrupt", this.onInterrupt);
9669
+ this.host.on("disconnected", this.onDisconnected);
9670
+ if (opts.autoStart ?? true) {
9671
+ queueMicrotask(() => this.play());
9672
+ }
9673
+ }
9674
+ get length() {
9675
+ return this.lines.length;
9676
+ }
9677
+ get index() {
9678
+ return this._index;
9679
+ }
9680
+ get state() {
9681
+ return this._state;
9682
+ }
9683
+ play() {
9684
+ if (this._state === "done" || this._state === "playing") return;
9685
+ this.pauseRequested = false;
9686
+ this._state = "playing";
9687
+ this.startNextLine();
9688
+ }
9689
+ resume() {
9690
+ this.play();
9691
+ }
9692
+ pause() {
9693
+ if (this._state === "done") return;
9694
+ this.pauseRequested = true;
9695
+ }
9696
+ next() {
9697
+ if (this._state === "done") return;
9698
+ if (this._state === "idle") {
9699
+ this.play();
9700
+ return;
9701
+ }
9702
+ const expectInterrupt = this._state === "playing";
9703
+ this.pauseRequested = false;
9704
+ this._state = "playing";
9705
+ this.clearTimers();
9706
+ this.supersedeCurrentLine(expectInterrupt);
9707
+ this.startNextLine();
9708
+ }
9709
+ stop() {
9710
+ this.finish("stopped", true);
9711
+ }
9712
+ // ─── internals ────────────────────────────────────────────────
9713
+ hasSpeakableLine() {
9714
+ return this.lines.some((l) => l.text.trim().length > 0);
9715
+ }
9716
+ startNextLine() {
9717
+ if (this._state !== "playing") return;
9718
+ if (!this.hasSpeakableLine()) {
9719
+ this.finish("finished");
9720
+ return;
9721
+ }
9722
+ let next = this._index + 1;
9723
+ for (; ; ) {
9724
+ if (next >= this.lines.length) {
9725
+ if (this.loop) {
9726
+ next = 0;
9727
+ } else {
9728
+ this.finish("finished");
9729
+ return;
9730
+ }
9731
+ }
9732
+ if (this.lines[next].text.trim()) break;
9733
+ next++;
9734
+ }
9735
+ this._index = next;
9736
+ this.currentMessageId = null;
9737
+ this.lineAcked = false;
9738
+ if (!this.host.isConnected) {
9739
+ this.finish("disconnected");
9740
+ return;
9741
+ }
9742
+ const line = this.lines[next];
9743
+ this.host.sayLine(line.text, line.textOnly);
9744
+ this.lineTimer = setTimeout(() => this.handleLineTimeout(), this.lineTimeoutMs);
9745
+ }
9746
+ handleBotResponse(r) {
9747
+ if (this._state === "done" || this._index < 0) return;
9748
+ if (r.messageId && this.retiredIds.has(r.messageId)) return;
9749
+ if (r.isInterjection) return;
9750
+ if (!this.lineAcked) {
9751
+ if (this.pendingStaleResponses > 0) {
9752
+ this.pendingStaleResponses--;
9753
+ if (r.messageId) this.retiredIds.add(r.messageId);
9754
+ return;
9755
+ }
9756
+ this.lineAcked = true;
9757
+ this.currentMessageId = r.messageId ?? null;
9758
+ this.selfInterruptPending = false;
9759
+ this.host.emitScriptLineStarted({
9760
+ index: this._index,
9761
+ text: this.lines[this._index].text,
9762
+ messageId: r.messageId
9763
+ });
9764
+ }
9765
+ if (this.currentMessageId && r.messageId && r.messageId !== this.currentMessageId) return;
9766
+ const line = this.lines[this._index];
9767
+ const completesOnResponse = line.textOnly || !this.host.willPlayScriptedAudio;
9768
+ if (completesOnResponse && r.isFinal) {
9769
+ this.onLineComplete();
9770
+ }
9771
+ }
9772
+ handleAudioComplete(messageId) {
9773
+ if (this._state === "done" || this._index < 0) return;
9774
+ if (messageId && this.retiredIds.has(messageId)) return;
9775
+ const line = this.lines[this._index];
9776
+ if (line.textOnly || !this.host.willPlayScriptedAudio) return;
9777
+ if (this.currentMessageId && messageId && messageId !== this.currentMessageId) return;
9778
+ this.onLineComplete();
9779
+ }
9780
+ handleLineTimeout() {
9781
+ if (this._state === "done") return;
9782
+ this.host.log(`script line ${this._index} timed out after ${this.lineTimeoutMs}ms; advancing`);
9783
+ this.onLineComplete();
9784
+ }
9785
+ onLineComplete() {
9786
+ this.clearTimers();
9787
+ if (this.lineGapMs > 0) {
9788
+ this.gapTimer = setTimeout(() => this.afterGap(), this.lineGapMs);
9789
+ } else {
9790
+ this.afterGap();
9791
+ }
9792
+ }
9793
+ afterGap() {
9794
+ this.gapTimer = null;
9795
+ if (this._state === "done") return;
9796
+ if (this.pauseRequested) {
9797
+ this._state = "paused";
9798
+ return;
9799
+ }
9800
+ this.startNextLine();
9801
+ }
9802
+ handleExternalInterrupt(data) {
9803
+ if (this._state === "done") return;
9804
+ if (data?.messageId && this.retiredIds.has(data.messageId)) return;
9805
+ if (this.selfInterruptPending) {
9806
+ this.selfInterruptPending = false;
9807
+ return;
9808
+ }
9809
+ this.finish("interrupted");
9810
+ }
9811
+ /**
9812
+ * Retire the current line because we're moving off it.
9813
+ * @param expectInterrupt true when the line is still in-flight (next()/stop() mid-line), so the
9814
+ * server will emit a self-induced `interrupt` we must not treat as external. false when the
9815
+ * line already completed (e.g. next() while paused), where no interrupt is coming.
9816
+ */
9817
+ supersedeCurrentLine(expectInterrupt) {
9818
+ if (this.lineAcked && this.currentMessageId) {
9819
+ this.retiredIds.add(this.currentMessageId);
9820
+ } else if (expectInterrupt && this.lines[this._index]?.textOnly) {
9821
+ this.pendingStaleResponses++;
9822
+ }
9823
+ if (expectInterrupt) this.selfInterruptPending = true;
9824
+ }
9825
+ clearTimers() {
9826
+ if (this.lineTimer) {
9827
+ clearTimeout(this.lineTimer);
9828
+ this.lineTimer = null;
9829
+ }
9830
+ if (this.gapTimer) {
9831
+ clearTimeout(this.gapTimer);
9832
+ this.gapTimer = null;
9833
+ }
9834
+ }
9835
+ finish(reason, interrupt = false) {
9836
+ if (this._state === "done") return;
9837
+ this.clearTimers();
9838
+ if (interrupt) {
9839
+ this.selfInterruptPending = true;
9840
+ if (this.host.isConnected) this.host.interrupt();
9841
+ }
9842
+ this._state = "done";
9843
+ this.host.off("botResponse", this.onBotResponse);
9844
+ this.host.off("audioPlaybackComplete", this.onAudioComplete);
9845
+ this.host.off("interrupt", this.onInterrupt);
9846
+ this.host.off("disconnected", this.onDisconnected);
9847
+ this.host.emitScriptComplete({ reason });
9848
+ this.resolveDone({ reason });
9849
+ }
9850
+ };
9851
+
9627
9852
  // src/client.ts
9628
9853
  var DEFAULT_SAMPLE_RATE = 24e3;
9629
9854
  var REST_UNAVAILABLE_MESSAGE = "REST API not available with session token auth. Use a server-side proxy for REST calls.";
@@ -9640,6 +9865,7 @@ var EstuaryClient = class extends TypedEventEmitter {
9640
9865
  _hasAutoInterrupted = false;
9641
9866
  _autoInterruptGraceTimer = null;
9642
9867
  _isLiveKitSpeaking = false;
9868
+ _activeScript = null;
9643
9869
  constructor(config) {
9644
9870
  super();
9645
9871
  if (!config.apiKey && !config.sessionToken) {
@@ -9714,6 +9940,10 @@ var EstuaryClient = class extends TypedEventEmitter {
9714
9940
  /** Disconnect from the server */
9715
9941
  async disconnect() {
9716
9942
  this.logger.info("Disconnecting...");
9943
+ if (this._activeScript && this._activeScript.state !== "done") {
9944
+ this._activeScript.stop();
9945
+ }
9946
+ this._activeScript = null;
9717
9947
  if (this._autoInterruptGraceTimer) {
9718
9948
  clearTimeout(this._autoInterruptGraceTimer);
9719
9949
  this._autoInterruptGraceTimer = null;
@@ -9735,6 +9965,59 @@ var EstuaryClient = class extends TypedEventEmitter {
9735
9965
  this.ensureConnected();
9736
9966
  this.socketManager.emitEvent("say_line", { text, text_only: textOnly });
9737
9967
  }
9968
+ /**
9969
+ * Script a sequence of prewritten lines. Lines are paced so each finishes before the next
9970
+ * is sent — required because say_line interrupts any in-progress response server-side, so
9971
+ * unpaced lines would stomp each other. Returns a controller (play/pause/resume/next/stop +
9972
+ * an awaitable `done`). Starting a new script stops any currently-active one.
9973
+ */
9974
+ playScript(lines, opts) {
9975
+ this.ensureConnected();
9976
+ if (this._activeScript && this._activeScript.state !== "done") {
9977
+ this._activeScript.stop();
9978
+ }
9979
+ const player = new ScriptPlayer(this.createScriptHost(), lines, opts);
9980
+ this._activeScript = player;
9981
+ return player;
9982
+ }
9983
+ /** Convenience alias of playScript() for fire-and-forget scripted sequences. */
9984
+ sayLines(lines, opts) {
9985
+ return this.playScript(lines, opts);
9986
+ }
9987
+ createScriptHost() {
9988
+ const self = this;
9989
+ return {
9990
+ sayLine(text, textOnly) {
9991
+ self.sayLine(text, textOnly);
9992
+ },
9993
+ interrupt() {
9994
+ self.interrupt();
9995
+ },
9996
+ on(event, listener) {
9997
+ self.on(event, listener);
9998
+ return self;
9999
+ },
10000
+ off(event, listener) {
10001
+ self.off(event, listener);
10002
+ return self;
10003
+ },
10004
+ get isConnected() {
10005
+ return self.isConnected;
10006
+ },
10007
+ get willPlayScriptedAudio() {
10008
+ return self.audioPlayer != null;
10009
+ },
10010
+ emitScriptLineStarted(info) {
10011
+ self.emit("scriptLineStarted", info);
10012
+ },
10013
+ emitScriptComplete(info) {
10014
+ self.emit("scriptComplete", info);
10015
+ },
10016
+ log(msg) {
10017
+ self.logger.debug(msg);
10018
+ }
10019
+ };
10020
+ }
9738
10021
  /** Interrupt the current bot response */
9739
10022
  interrupt(messageId) {
9740
10023
  this.ensureConnected();