@inetafrica/open-claudia 3.5.27 → 3.5.28

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/CHANGELOG.md CHANGED
@@ -1,13 +1,30 @@
1
1
  # Changelog
2
2
 
3
- ## 3.5.27
3
+ ## 3.5.28
4
4
 
5
- Calls answer in about two seconds again, and say something while they work (Sumeet, 11 Oct 08:24 EAT on a walk, after a call where "hello" took 8.5 s and a 79 s lookup was never heard: "Yeah, let's give it a shot"). A canary build for the owner's own bot first. It builds on 3.5.24; the Browser canaries 3.5.25 and 3.5.26 are not in it yet.
5
+ The Browser app is always on: opening it while the bot is not browsing opens the bot's browser, instead of a dead end (Sumeet, 10 Oct: "half the time i have to ask you switch on the browser"; 11 Oct 07:43 EAT network-mon's Browser showed only "isn't open right now"). A canary build for the Browser canary bot, on 3.5.26; it does not carry 3.5.27 (calls, Sumeet's own bot).
6
6
 
7
- - **A quick voice says a fitting line right away** (`channels/kazee/call/quick-voice.js`). A kept-warm small model, one process per call through the provider's own utility invocation, hears each utterance and says one short line ("Sure, let me check the call logs.", "Yep, I'm here.") within about a second, then a brief "still on it" line whenever the full agent leaves the caller in 8 s of silence, at most 3 per turn. It never answers: every utterance still goes to the full agent. The line is dropped if it is not ready in 3.5 s, if the full agent already spoke, if the caller is talking, or once a newer utterance took over. Off unless `KAZEE_CALL_QUICK_VOICE=on`; `KAZEE_CALL_QUICK_MODEL` (default `claude-haiku-4-5`), `_EFFORT` (`low`) and `_THINKING` (off) tune it. Measured on 23 call lines with traps ("Did you hear me?", "Are you sure?"): Haiku 4.5 finished its line in 0.8-1.1 s with thinking off (2-3 s with it on), Sonnet 5 in 1.7-1.9 s, and both kept every rule.
8
- - **The full agent speaks first, then works.** The live-call guidance used to forbid "speculative progress", so a lookup went silent until it ended. Now the first action is a 3-12 word call-say ("Sure, let me check the call logs."), then a progress line about every ten seconds. Replayed on the four turns of the 11 Oct call, 3 times per effort level: the holding line came first 3 of 3 times at max, xhigh, high and medium (the old guidance: 1 of 3 at xhigh), simple replies unchanged. With the quick voice on, the agent skips its own acknowledgement and starts with substance.
9
- - **A sound with no words no longer silences the answer.** Speech start mutes the running call turn in case it is a new ask; when the recogniser found no words (wind, footsteps, a cough) the turn stayed muted for good, its call-say got 409 and its final answer was never spoken. The session now reports a noise-only turn and the turn gets its voice back, unless someone is still talking or another segment is pending.
10
- - **Tests:** `test-call-quick-voice.js` (new, in `npm test`), `test-kazee-call-manager.js`, `test-kazee-call-session.js`, `test-provider-prompt-parity.js`.
7
+ - **`POST /api/browser/start`** (Agent Space's control token only, like every Browser app route). With no browser open, the bot's own default session opens on a start page (`https://www.google.com/`), with the bot's own agent-browser environment and socket folder (`AGENT_BROWSER_SOCKET_DIR`, else `~/.agent-browser`), exactly as its own `agent-browser open` would, so the bot carries on in that tab. A browser already open is answered as it is (the newest session), and two people opening the app at once start one browser. `GET /api/browser/control` says `start: true`; Agent Space's front door (223b978) asks only bots that say so.
8
+ - **Tests:** `test-browser-control.js`, `test-web-browser-app.js`.
9
+
10
+ ## 3.5.26
11
+
12
+ The Browser app's phone and web view stays sharp after a page load. A canary build for the Browser canary bot first; the fleet waits for Sumeet's OK.
13
+
14
+ - **Sharp after every page.** On the canary's Chrome 150 (Linux) the driver's pictures dropped from the view's density to 1x at the first page load (the view kept its density; the picture did not). After a main-frame navigation or a load, the window is now resized by a pixel and back, which brings the density back: 906x1760 pictures for a 412x800 phone at 2.2x on duckduckgo.com, en.m.wikipedia.org and example.com, 1 to 4 s after each load.
15
+ - **Tests:** `test-browser-drive.js`.
16
+
17
+ ## 3.5.25
18
+
19
+ The Browser app feels like a browser on the phone and on the web (Sumeet, 10 Oct 08:53 EAT on his phone: "it feels like a browser on my phone… like Google Chrome app", and 09:18: "the web and mobile still need to also feel native… don't forget the website"). A canary build for the Browser canary bot first; the fleet waits for his OK.
20
+
21
+ - **The bot's tab takes the driver's screen.** With Chrome in its own container, a take (or a `fit` when the screen changes: a turned phone, a resized panel, a keyboard that opens) that carries the driver's screen (`viewport`: CSS size, pixel density, phone layout, touch) fits the bot's tab to it through `core/browser-drive.js`. A phone gets the page's phone layout, full screen and sharp; the web gets a desktop page at the panel's size. Handing back puts the tab back to its own size. The browser's identity (user agent) never changes, so sites signed into stay signed in after the hand back. `GET /api/browser/control` says `fit: true`.
22
+ - **The driver's input goes straight to Chrome,** through the view's own DevTools session, in order. Touches keep their timing, so a swipe flings; typed text is inserted whole (`input_text`, at most 500 characters: accents, emoji, the keyboard's corrections); every point is scaled by the view's density, because Chrome reads input in the drawn picture's pixels. A bot without the view takes `input_text` as key presses.
23
+ - **The page's text fields.** The view reads where the page's text fields are and what kind they are (never a value) and streams them as `fields` events, so the Browser page opens the phone's own keyboard of the right kind the moment a field is tapped.
24
+ - **Pictures.** The view streams its own screencast at the driver's size (at most 1.6 million pixels, 30 a second). `/api/browser/stream` serves it while someone drives, up to 30 fps, and keeps the newest frame while a slow viewer drains instead of dropping it, so the last picture of a scroll always arrives.
25
+ - **A hand back wakes the bot only when it waits for that person:** it sent them an Open Browser card (cards now record `openApp`), or one of its browser commands was refused while they drove (the tool guard marks it). Otherwise the hand back is only logged. On 9 Oct a test hand back set off a 28-minute tangent on the canary.
26
+ - **Correction to 3.5.24:** in container mode, sessions are not pinned to tabs (Agent Space dropped `AGENT_BROWSER_PIN_TAB` in a98f239); a session uses the tab that is open.
27
+ - **Tests:** `test-browser-drive.js` (new), `test-browser-control.js`, `test-web-browser-app.js`.
11
28
 
12
29
  ## 3.5.24
13
30
 
@@ -5,7 +5,6 @@ const { CallLeaseHeartbeat } = require("./lease");
5
5
  const { publicCallIdentity, requestRoomAccess, supportedIceServers } = require("./access");
6
6
  const { canonicalForChannel } = require("../../../core/identity");
7
7
  const { KazeeCallSession } = require("./session");
8
- const quickVoice = require("./quick-voice");
9
8
 
10
9
  const DEFAULT_JANUS_URL = "wss://janus.kazee.africa/ws";
11
10
  const DEFAULT_ICE_SERVERS = [
@@ -15,34 +14,6 @@ const DEFAULT_ICE_SERVERS = [
15
14
  // FCM push TTL is 60s and chat-central treats ringing logs as fresh for 90s.
16
15
  // This is the outbound invitation deadline; terminal server events end it early.
17
16
  const RING_TIMEOUT_MS = 60_000;
18
- const qlog = (msg) => console.log(`${new Date().toISOString()} Kazee call quick: ${msg}`);
19
- // The quick line must be ready this soon after the words, or it is skipped:
20
- // by then the full agent is close to speaking itself.
21
- const QUICK_LINE_MAX_MS = 3500;
22
- // While the full agent works: a "still on it" line after this much quiet,
23
- // at most a few per turn, never after the answer.
24
- const QUICK_FILLER_QUIET_MS = 8000;
25
- const QUICK_FILLER_MAX = 3;
26
- const QUICK_TICK_MS = 500;
27
-
28
- // The provider the call's chat runs on: the quick voice uses its CLI and
29
- // credentials, as the full agent's turns do.
30
- function chatProvider({ adapter, chatId, userId }) {
31
- const { runInChat } = require("../../../core/context");
32
- const { conversationKeyFor, roomKeyFor } = require("../../../core/identity");
33
- const transport = adapter?.type || "kazee";
34
- const id = runInChat({
35
- adapter, channelId: String(chatId), userId: String(userId), transport,
36
- canonicalUserId: canonicalForChannel(transport, String(userId)),
37
- conversationKey: conversationKeyFor(transport, String(chatId)),
38
- roomKey: roomKeyFor(transport, String(chatId)),
39
- }, () => {
40
- const state = require("../../../core/state");
41
- return state.getActiveProvider(state.currentState());
42
- });
43
- return id ? require("../../../core/providers").registry.getProvider(id) : null;
44
- }
45
-
46
17
  const OUTGOING_GREETING = "Hey, it's Claudia. Can you hear me okay?";
47
18
  // Inbound must greet too: callers get silence until they speak first, which
48
19
  // reads as a dead call (live test 2026-08-08 — media was up, caller hung up).
@@ -50,8 +21,7 @@ const INCOMING_GREETING = "Hey, it's Claudia — you're through. Can you hear me
50
21
 
51
22
  class KazeeCallManager {
52
23
  constructor({ adapter, SessionImpl = KazeeCallSession, janusUrl = process.env.KAZEE_JANUS_URL || DEFAULT_JANUS_URL,
53
- iceServers = DEFAULT_ICE_SERVERS, deviceId = randomUUID(), resolveQuickProvider = chatProvider, quickSpawn = undefined,
54
- quickSettings = quickVoice.quickVoiceSettings, quickTiming = {} } = {}) {
24
+ iceServers = DEFAULT_ICE_SERVERS, deviceId = randomUUID() } = {}) {
55
25
  this.adapter = adapter;
56
26
  this.SessionImpl = SessionImpl;
57
27
  this.janusUrl = janusUrl;
@@ -60,10 +30,6 @@ class KazeeCallManager {
60
30
  this.deviceId = deviceId;
61
31
  this._pending = null;
62
32
  this._ringTimer = null;
63
- this.resolveQuickProvider = resolveQuickProvider;
64
- this.quickSpawn = quickSpawn;
65
- this.quickSettings = quickSettings;
66
- this.quickTiming = { lineMaxMs: QUICK_LINE_MAX_MS, fillerQuietMs: QUICK_FILLER_QUIET_MS, fillerMax: QUICK_FILLER_MAX, tickMs: QUICK_TICK_MS, ...quickTiming };
67
33
  }
68
34
 
69
35
  wire(socket) {
@@ -123,7 +89,6 @@ class KazeeCallManager {
123
89
  // Several terminal paths clear this.session before close() calls back.
124
90
  // Invalidate the closed call's work even when it no longer owns the slot.
125
91
  session?._voiceTurn?.controller.abort("call-ended");
126
- this._stopQuickVoice(session);
127
92
  if (this.session !== session) return;
128
93
  this._detachSession(session);
129
94
  if (this.adapter._socket?.connected) this.adapter._socket.emit("call:end", {
@@ -133,7 +98,6 @@ class KazeeCallManager {
133
98
 
134
99
  _detachSession(session) {
135
100
  session?._voiceTurn?.controller.abort("call-ended");
136
- this._stopQuickVoice(session);
137
101
  if (this.session === session) this.session = null;
138
102
  }
139
103
 
@@ -187,14 +151,12 @@ class KazeeCallManager {
187
151
  ...this._sessionOptions(config, access, boundIdentity, pending.controller.signal), greeting: INCOMING_GREETING,
188
152
  onTranscript: (text, meta) => this._transcript(text, { ...payload, ...boundIdentity }, meta, session),
189
153
  onSpeechStart: () => this._speechStart(session),
190
- onSpeechNoise: () => this._speechNoise(session),
191
154
  onMediaReady: () => this._mediaReady(session),
192
155
  onEnded: reason => { session?._leaseHeartbeat?.close(); pending.controller.abort(); this._sessionEnded(session, reason); },
193
156
  });
194
157
  session.callIdentity = boundIdentity;
195
158
  session.chatId = chatId;
196
159
  this.session = session;
197
- this._startQuickVoice(session, chatId, callerId);
198
160
  this._manageLease(session, access);
199
161
  await session.start();
200
162
  if (this.session !== session || session.closed || pending.controller.signal.aborted) return;
@@ -236,14 +198,12 @@ class KazeeCallManager {
236
198
  ...this._sessionOptions(config, access, identity, pending.controller.signal), asCaller: true, greeting: OUTGOING_GREETING,
237
199
  onTranscript: (text, meta) => this._transcript(text, remote, meta, session),
238
200
  onSpeechStart: () => this._speechStart(session),
239
- onSpeechNoise: () => this._speechNoise(session),
240
201
  onMediaReady: () => this._mediaReady(session),
241
202
  onEnded: reason => { session?._leaseHeartbeat?.close(); pending.controller.abort(); this._clearRingTimer(); this._sessionEnded(session, reason); },
242
203
  });
243
204
  session.callIdentity = identity;
244
205
  session.chatId = chat;
245
206
  this.session = session;
246
- this._startQuickVoice(session, chat, target);
247
207
  this._manageLease(session, access);
248
208
  await session.start();
249
209
  if (this.session !== session || session.closed || pending.controller.signal.aborted) throw new Error("Call cancelled");
@@ -287,96 +247,6 @@ class KazeeCallManager {
287
247
  // complete transcript exists; a cough must not discard an accepted ask.
288
248
  }
289
249
 
290
- // The sound that muted the turn had no words in it: not a new request, so
291
- // the answer being worked on may speak again (11 Oct: wind while walking
292
- // muted a lookup's answer for good, and the caller heard nothing).
293
- _speechNoise(session) {
294
- const turn = session?._voiceTurn;
295
- if (!turn?.muted || turn.controller.signal.aborted || this.session !== session || session.closed) return;
296
- turn.muted = false;
297
- console.log(`${new Date().toISOString()} Kazee call noise: muted turn resumed`);
298
- }
299
-
300
- // ── Quick voice ───────────────────────────────────────────────────
301
-
302
- _startQuickVoice(session, chatId, userId) {
303
- let settings;
304
- try { settings = this.quickSettings(); } catch (_) { return; }
305
- if (!settings?.enabled) return;
306
- let provider = null;
307
- try { provider = this.resolveQuickProvider({ adapter: this.adapter, chatId, userId }); } catch (_) {}
308
- if (!quickVoice.QuickVoiceProcess.supported(provider)) {
309
- qlog(`off: provider ${provider?.id || "none"} has no warm stream`);
310
- return;
311
- }
312
- const quick = quickVoice.track(new quickVoice.QuickVoiceProcess({
313
- provider, settings, spawnImpl: this.quickSpawn, log: qlog,
314
- systemPrompt: quickVoice.buildQuickPrompt({ ownerName: quickVoice.ownerDisplayName() }),
315
- }));
316
- session._quick = quick;
317
- // The quick voice says the first line itself; the session's delayed cue
318
- // would only double it.
319
- session.acknowledgement = async () => null;
320
- try { quick.start(); } catch (error) {
321
- qlog(`start failed: ${error.message}`);
322
- session._quick = null;
323
- quick.stop().catch?.(() => {});
324
- }
325
- }
326
-
327
- _stopQuickVoice(session) {
328
- const quick = session?._quick;
329
- if (!quick) return;
330
- session._quick = null;
331
- quick.stop().catch?.(() => {});
332
- }
333
-
334
- // One utterance: a short fitting line as soon as the quick voice has it,
335
- // then a "still on it" line whenever the full agent leaves the caller in
336
- // silence, until its answer arrives. Never over the caller, never after
337
- // the answer, never once a newer utterance took over.
338
- _quickTurn(session, turn, text) {
339
- const quick = session._quick;
340
- const timing = this.quickTiming;
341
- const live = () => !turn.muted && !turn.done && !turn.controller.signal.aborted &&
342
- !session.closed && this.session === session && session._voiceTurn === turn && session._quick === quick;
343
- const sayLine = async (message, label, { maxMs = Infinity, unlessSpoke = false } = {}) => {
344
- const asked = Date.now();
345
- const { generation, text: reply } = quick.say(message);
346
- const cancel = () => quick.cancel(generation);
347
- turn.controller.signal.addEventListener("abort", cancel, { once: true });
348
- let line = "";
349
- try { line = quickVoice.spokenLine(await reply); }
350
- finally { turn.controller.signal.removeEventListener("abort", cancel); }
351
- const ready = Date.now() - asked;
352
- if (!line || !live() || ready > maxMs || (unlessSpoke && turn.spoke) || session.callerTalking?.()) {
353
- qlog(`${label} skipped ready=${ready}ms`);
354
- return false;
355
- }
356
- qlog(`${label} ready=${ready}ms chars=${line.length}`);
357
- const spoken = await session.speak(line);
358
- if (spoken === false || !live()) return false;
359
- this._rememberSpeech(session, "assistant", line);
360
- return true;
361
- };
362
- (async () => {
363
- await sayLine(quickVoice.utteranceMessage(text), "line", { maxMs: timing.lineMaxMs, unlessSpoke: true });
364
- let fillers = 0;
365
- let lastFillerAt = Date.now();
366
- while (fillers < timing.fillerMax && live()) {
367
- await new Promise((resolve) => { const timer = setTimeout(resolve, timing.tickMs); timer.unref?.(); });
368
- if (!live()) break;
369
- if (session.speechBusy?.() || session.callerTalking?.()) continue;
370
- const quietSince = Math.max(turn.startedAt, lastFillerAt, session._lastSpokeAt || 0);
371
- if (Date.now() - quietSince < timing.fillerQuietMs) continue;
372
- fillers += 1;
373
- lastFillerAt = Date.now();
374
- await sayLine(quickVoice.fillerMessage(text, (Date.now() - turn.startedAt) / 1000), `filler ${fillers}`);
375
- lastFillerAt = Date.now();
376
- }
377
- })().catch((error) => qlog(`turn failed: ${error.message}`));
378
- }
379
-
380
250
  _rememberSpeech(session, role, text) {
381
251
  const history = session._voiceHistory ||= [];
382
252
  if (role === "assistant" && history.at(-1)?.role === "assistant") {
@@ -392,7 +262,7 @@ class KazeeCallManager {
392
262
  const speaker = String(meta.display || "").trim();
393
263
  const attributed = (meta.participants || 0) > 1 && speaker ? `${speaker}: ${text}` : text;
394
264
  session._voiceTurn?.controller.abort("superseded");
395
- const turn = { controller: new AbortController(), muted: false, done: false, spoke: false, startedAt: Date.now() };
265
+ const turn = { controller: new AbortController(), muted: false };
396
266
  session._voiceTurn = turn;
397
267
  const callAdapter = Object.create(this.adapter);
398
268
  callAdapter.callHistory = (session._voiceHistory || []).map(entry => ({ ...entry }));
@@ -407,8 +277,6 @@ class KazeeCallManager {
407
277
  callAdapter.suppressChatVisibility = true;
408
278
  // Prompt builder switches to phone-call brevity when it sees this.
409
279
  callAdapter.liveCall = true;
410
- // The quick voice says the first line; the prompt then skips its own.
411
- callAdapter.quickVoice = !!session._quick?.ready();
412
280
  const spokenSentences = [];
413
281
  callAdapter.isCallTurnActive = () => !turn.muted && !turn.controller.signal.aborted &&
414
282
  !session.closed && this.session === session && session._voiceTurn === turn;
@@ -419,7 +287,6 @@ class KazeeCallManager {
419
287
  if (!callAdapter.isCallTurnActive()) return false;
420
288
  const spoken = await session.speak(response);
421
289
  if (spoken !== false && callAdapter.isCallTurnActive()) {
422
- turn.spoke = true;
423
290
  this._rememberSpeech(session, "assistant", response);
424
291
  return true;
425
292
  }
@@ -431,7 +298,6 @@ class KazeeCallManager {
431
298
  return spoken;
432
299
  };
433
300
  callAdapter.send = async (_channelId, response) => {
434
- turn.done = true;
435
301
  const remainder = require("../../../core/call-speech").unspokenSuffix(response, spokenSentences);
436
302
  if (remainder) await speak(remainder);
437
303
  return true;
@@ -442,7 +308,6 @@ class KazeeCallManager {
442
308
  console.warn(`Kazee call ignored file voice response ${voicePath}; streamed TTS is active`);
443
309
  return true;
444
310
  };
445
- if (callAdapter.quickVoice) this._quickTurn(session, turn, attributed);
446
311
  this.adapter._emit("message", {
447
312
  adapter: callAdapter,
448
313
  channelId: String(incoming.chatId),
@@ -54,7 +54,7 @@ const ACK_DELAY_MS = 2800;
54
54
  class KazeeCallSession {
55
55
  constructor({ roomId, chatId, callerId, botUserId, janusUrl, janusToken, janusTokenExpiresAt, roomToken, roomTokenExpiresAt, managedRoom = false, refreshRoomAccess, iceServers = [], iceCredentialsExpiresAt, onTranscript, onEnded, onMediaReady,
56
56
  maxCallMs, idleMs, finalWaitMs, synthesize = synthesizePcm, synthesizeStream = null, asCaller = false, greeting = null,
57
- turnDecision = detectTurn, acknowledgement = selectAcknowledgement, onSpeechStart, onSpeechNoise,
57
+ turnDecision = detectTurn, acknowledgement = selectAcknowledgement, onSpeechStart,
58
58
  JanusClientImpl = JanusClient, PeerConnectionImpl = RTCPeerConnection, SpeechStreamImpl = SpeechStream } = {}) {
59
59
  this.roomId = Number(roomId);
60
60
  this.chatId = String(chatId);
@@ -83,7 +83,6 @@ class KazeeCallSession {
83
83
  this._transportExpiryTimer = null;
84
84
  this.onTranscript = onTranscript || (() => {});
85
85
  this.onSpeechStart = onSpeechStart || (() => {});
86
- this.onSpeechNoise = onSpeechNoise || (() => {});
87
86
  this.turnDecision = turnDecision;
88
87
  this.acknowledgement = acknowledgement;
89
88
  this._cueController = null;
@@ -586,7 +585,6 @@ class KazeeCallSession {
586
585
  if (countWords(trimmed) < MIN_DISPATCH_WORDS) {
587
586
  clog(`Kazee call noise turn dropped: feed=${feedId} words=${countWords(trimmed)}`);
588
587
  if (!this._speakActive && !this._speakQueue.length) this._stopThinking();
589
- this._speechWasNoise(feedId);
590
588
  return;
591
589
  }
592
590
  }
@@ -595,7 +593,7 @@ class KazeeCallSession {
595
593
  // Empty finalization (noise commit): nothing dispatches, no reply is
596
594
  // coming — don't tick into the void.
597
595
  else if (!this._speakActive && !this._speakQueue.length) this._stopThinking();
598
- if (!trimmed) { this._speechWasNoise(feedId); return; }
596
+ if (!trimmed) return;
599
597
  this.onTranscript(text, {
600
598
  feedId,
601
599
  display: sub?.display || "",
@@ -604,16 +602,6 @@ class KazeeCallSession {
604
602
  });
605
603
  }
606
604
 
607
- // Speech start muted the turn being worked on, in case it was a new ask. A
608
- // sound the recogniser found no words in (wind, footsteps, a cough) was not:
609
- // unless someone is still talking or another segment is pending, the
610
- // muted turn gets its voice back.
611
- _speechWasNoise(feedId) {
612
- if ([...this.subscribers.values()].some(peer => peer?.vad?.active || peer?.turnGate?.pending ||
613
- peer?.awaitingWordsSince || peer?.segments?.size)) return;
614
- this.onSpeechNoise({ feedId });
615
- }
616
-
617
605
  _reportMediaReady() {
618
606
  if (this.closed || this._reportedMediaReady || !this._sentRtp || !this._receivedRtp) return;
619
607
  this._reportedMediaReady = true;
@@ -763,16 +751,6 @@ class KazeeCallSession {
763
751
  sub.stt.audio(pcm16).catch((error) => console.error("Kazee call STT send:", error.message));
764
752
  }
765
753
 
766
- // For the quick voice: someone is talking (or a turn is being decided), and
767
- // speech is playing or queued.
768
- callerTalking() {
769
- return [...this.subscribers.values()].some(sub => sub?.vad?.active || sub?.turnGate?.pending || sub?.awaitingWordsSince);
770
- }
771
-
772
- speechBusy() {
773
- return !!(this.speaking || this._speakActive || this._speakQueue.length);
774
- }
775
-
776
754
  speak(text) {
777
755
  if (this.closed || !String(text || "").trim()) return Promise.resolve(false);
778
756
  return new Promise((resolve, reject) => {