@teamlearners/clawops 0.16.5 → 0.17.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
@@ -45,6 +45,36 @@ agent.on('call_start', async (call) => {
45
45
  await agent.serve(); // Ctrl+C로 종료
46
46
  ```
47
47
 
48
+ ### Outbound 발신 Prewarm (낮은 첫 음성 latency)
49
+
50
+ outbound 통화에서 상대 응답 직후 첫 음성까지의 지연을 줄이기 위해, `ClawOpsAgent` 는
51
+ control WS 의 `call.outbound_ready` 이벤트 수신 즉시 LLM WebSocket 을 미리 연결하고
52
+ 첫 audio delta 를 메모리에 누적한다 (prewarm + first-audio prebuffer). media WS 가 연결되면
53
+ 누적된 chunk 를 flush 하여 사용자가 첫 음성을 빠르게 듣게 한다.
54
+
55
+ ```typescript
56
+ const agent = new ClawOpsAgent({
57
+ from: '07012341234',
58
+ session: new OpenAIRealtime({ systemPrompt: '...' }),
59
+ prewarmEnabled: true, // default true
60
+ });
61
+ ```
62
+
63
+ 비용/효과 검증 단계에서는 `prewarmEnabled: false` 로 비활성화할 수 있다. 동작 측정은
64
+ `[PREWARM-T]` 로그 마커(`start` / `done` / `failed` / `attach` / `first-audio`)를 grep
65
+ 하여 elapsed 를 계산한다.
66
+
67
+ **한계 / 비목표**
68
+
69
+ - **동시 outbound 통화 1건 가정** — `ClawOpsAgent` 1 인스턴스의 `session` 객체는 prewarm 시
70
+ 단일 `BufferingCall` 을 공유한다. 같은 인스턴스로 동시 outbound 통화를 발신하면 prewarm
71
+ race 가 발생할 수 있다. 다중 동시 outbound 가 필요하면 통화별로 별도 `ClawOpsAgent`
72
+ 인스턴스를 사용하거나, session factory 패턴 도입이 필요하다 (후속 과제).
73
+ - **Session 타입별 효과 차이** — Realtime (OpenAI / Gemini) 에서 LLM WS handshake +
74
+ session.update 가 prewarm 으로 숨겨지므로 latency 절감 효과가 가장 크다. 반면
75
+ `PipelineSession` 은 STT / LLM / TTS 가 lazy 연결되므로, prewarm 단계에서는 STT 루프
76
+ 기동과 greeting kickoff 정도만 선행되어 latency 절감 효과가 제한적이다.
77
+
48
78
  ### Call Transfer (통화 전환)
49
79
 
50
80
  AI가 통화 중 다른 번호로 전환할 수 있습니다. Blind(즉시)와 Warm(안내 후) 모드를 지원합니다.
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunkTFYE5DWP_cjs = require('../chunk-TFYE5DWP.cjs');
3
+ var chunkZOSOGI2J_cjs = require('../chunk-ZOSOGI2J.cjs');
4
4
  var pino = require('pino');
5
5
  var fs = require('fs');
6
6
  var path = require('path');
@@ -1079,7 +1079,7 @@ var MAX_ERROR_MESSAGE_LENGTH = 200;
1079
1079
  function getSdkInfo() {
1080
1080
  return {
1081
1081
  name: "clawops-node",
1082
- version: chunkTFYE5DWP_cjs.VERSION,
1082
+ version: chunkZOSOGI2J_cjs.VERSION,
1083
1083
  runtime: `node/${process.versions.node}`,
1084
1084
  os: `${process.platform}/${os__default.default.arch()}`
1085
1085
  };
@@ -1759,10 +1759,15 @@ var ClawOpsAgent = class _ClawOpsAgent {
1759
1759
  _holdAudioChunks = null;
1760
1760
  _rxGain;
1761
1761
  _txGain;
1762
+ _prewarmTasks = /* @__PURE__ */ new Map();
1763
+ _prewarmFailed = /* @__PURE__ */ new Set();
1764
+ /** prewarm 세션이 실제 CallSession 에 attach 완료된 callId. attached 이후의 stop() 은 정상 종료 경로가 책임진다. */
1765
+ _prewarmAttached = /* @__PURE__ */ new Set();
1766
+ _prewarmEnabled;
1762
1767
  constructor(options) {
1763
1768
  this._apiKey = options.apiKey ?? process.env["CLAWOPS_API_KEY"] ?? "";
1764
1769
  this._accountId = options.accountId ?? process.env["CLAWOPS_ACCOUNT_ID"] ?? "";
1765
- this._baseUrl = options.baseUrl ?? chunkTFYE5DWP_cjs.DEFAULT_BASE_URL;
1770
+ this._baseUrl = options.baseUrl ?? chunkZOSOGI2J_cjs.DEFAULT_BASE_URL;
1766
1771
  this._fromNumber = options.from;
1767
1772
  this._session = options.session;
1768
1773
  this._recording = options.recording ?? false;
@@ -1772,6 +1777,7 @@ var ClawOpsAgent = class _ClawOpsAgent {
1772
1777
  this._passiveDtmfDebounceMs = options.passiveDtmfDebounceMs ?? 500;
1773
1778
  this._rxGain = _ClawOpsAgent._validateGain("rxGain", options.rxGain ?? 1);
1774
1779
  this._txGain = _ClawOpsAgent._validateGain("txGain", options.txGain ?? 1);
1780
+ this._prewarmEnabled = options.prewarmEnabled ?? true;
1775
1781
  if (options.tracing) {
1776
1782
  setTracingConfig(options.tracing);
1777
1783
  }
@@ -1784,7 +1790,7 @@ var ClawOpsAgent = class _ClawOpsAgent {
1784
1790
  }
1785
1791
  static _validateGain(name, gain) {
1786
1792
  if (typeof gain !== "number" || !Number.isFinite(gain) || gain < 0) {
1787
- throw new chunkTFYE5DWP_cjs.AgentError(`${name}=${gain} must be a finite number >= 0`);
1793
+ throw new chunkZOSOGI2J_cjs.AgentError(`${name}=${gain} must be a finite number >= 0`);
1788
1794
  }
1789
1795
  return gain;
1790
1796
  }
@@ -1798,7 +1804,7 @@ var ClawOpsAgent = class _ClawOpsAgent {
1798
1804
  tool(nameOrTool, description, parameters, handler) {
1799
1805
  if (typeof nameOrTool === "string") {
1800
1806
  if (!description || !parameters || !handler) {
1801
- throw new chunkTFYE5DWP_cjs.AgentError(
1807
+ throw new chunkZOSOGI2J_cjs.AgentError(
1802
1808
  "tool(name, description, parameters, handler) requires all arguments."
1803
1809
  );
1804
1810
  }
@@ -1834,10 +1840,10 @@ var ClawOpsAgent = class _ClawOpsAgent {
1834
1840
  async connect() {
1835
1841
  if (this._controlWs) return;
1836
1842
  if (!this._apiKey) {
1837
- throw new chunkTFYE5DWP_cjs.AgentError("API key is required. Set CLAWOPS_API_KEY or pass apiKey option.");
1843
+ throw new chunkZOSOGI2J_cjs.AgentError("API key is required. Set CLAWOPS_API_KEY or pass apiKey option.");
1838
1844
  }
1839
1845
  if (!this._accountId) {
1840
- throw new chunkTFYE5DWP_cjs.AgentError(
1846
+ throw new chunkZOSOGI2J_cjs.AgentError(
1841
1847
  "Account ID is required. Set CLAWOPS_ACCOUNT_ID or pass accountId option."
1842
1848
  );
1843
1849
  }
@@ -1861,7 +1867,7 @@ var ClawOpsAgent = class _ClawOpsAgent {
1861
1867
  } catch {
1862
1868
  }
1863
1869
  } catch (err) {
1864
- throw new chunkTFYE5DWP_cjs.AgentConnectionError(
1870
+ throw new chunkZOSOGI2J_cjs.AgentConnectionError(
1865
1871
  `Failed to connect to ClawOps: ${err instanceof Error ? err.message : String(err)}`
1866
1872
  );
1867
1873
  }
@@ -1912,7 +1918,7 @@ var ClawOpsAgent = class _ClawOpsAgent {
1912
1918
  });
1913
1919
  if (resp.status !== 201) {
1914
1920
  const error = await resp.json();
1915
- throw new chunkTFYE5DWP_cjs.AgentError(`\uBC1C\uC2E0 \uC2E4\uD328 (${resp.status}): ${error["error"] ?? ""}`);
1921
+ throw new chunkZOSOGI2J_cjs.AgentError(`\uBC1C\uC2E0 \uC2E4\uD328 (${resp.status}): ${error["error"] ?? ""}`);
1916
1922
  }
1917
1923
  const data = await resp.json();
1918
1924
  const callSession = new CallSession({
@@ -1930,6 +1936,9 @@ var ClawOpsAgent = class _ClawOpsAgent {
1930
1936
  callSession.setLogger(this._log);
1931
1937
  this._activeSessions.set(callSession.callId, callSession);
1932
1938
  this._log.info("Outbound call initiated: %s -> %s (%s)", this._fromNumber, to, callSession.callId);
1939
+ if (this._prewarmEnabled) {
1940
+ this._startPrewarm(callSession.callId);
1941
+ }
1933
1942
  return callSession;
1934
1943
  }
1935
1944
  _handleIncoming(event) {
@@ -1966,6 +1975,34 @@ var ClawOpsAgent = class _ClawOpsAgent {
1966
1975
  session._markEnded();
1967
1976
  this._activeSessions.delete(callId);
1968
1977
  }
1978
+ void this._cleanupPrewarm(callId);
1979
+ }
1980
+ /**
1981
+ * Drop prewarm bookkeeping for a callId. Used on hangup/failure paths.
1982
+ *
1983
+ * prewarm 이 진행 중이거나 완료됐지만 attach 전에 호출되면 LLM WS 가 leak 되므로
1984
+ * race 후 session.stop() 으로 정리한다. (TS 에는 Promise.cancel 이 없어 Python
1985
+ * 의 task.cancel() 등가물은 _session.stop() 호출이다.)
1986
+ *
1987
+ * 이미 attach 된 callId 면 stop() 을 호출하지 않는다 — 정상 종료 경로 (call-session
1988
+ * finally) 가 책임지기 때문이다.
1989
+ */
1990
+ async _cleanupPrewarm(callId) {
1991
+ const task = this._prewarmTasks.get(callId);
1992
+ const attached = this._prewarmAttached.has(callId);
1993
+ this._prewarmTasks.delete(callId);
1994
+ this._prewarmFailed.delete(callId);
1995
+ this._prewarmAttached.delete(callId);
1996
+ if (!task || attached) return;
1997
+ try {
1998
+ await task;
1999
+ } catch {
2000
+ }
2001
+ try {
2002
+ await this._session.stop();
2003
+ } catch (err) {
2004
+ this._log.warn({ err, callId }, "prewarm cleanup stop() failed");
2005
+ }
1969
2006
  }
1970
2007
  _handleOutboundReady(event) {
1971
2008
  const callId = event["callId"];
@@ -1987,16 +2024,60 @@ var ClawOpsAgent = class _ClawOpsAgent {
1987
2024
  }
1988
2025
  this._activeSessions.set(callId, session);
1989
2026
  }
2027
+ if (this._prewarmEnabled) {
2028
+ this._startPrewarm(callId);
2029
+ }
1990
2030
  if (mediaUrl) {
1991
2031
  this._log.info("Outbound call answered: %s -> %s (%s)", this._fromNumber, session.toNumber, callId);
1992
2032
  this._safeStartCallSession(session, mediaUrl, callId);
1993
2033
  }
1994
2034
  }
2035
+ /**
2036
+ * Start the LLM session prewarm task for the given callId. Safe to call
2037
+ * multiple times — only the first invocation starts the task. Failures are
2038
+ * recorded in _prewarmFailed so the call-session path can fall back to start().
2039
+ */
2040
+ _startPrewarm(callId) {
2041
+ if (this._prewarmTasks.has(callId)) return;
2042
+ const sessionHandler = this._session;
2043
+ if (typeof sessionHandler.prewarm !== "function") return;
2044
+ const PREWARM_TIMEOUT_MS = 1e4;
2045
+ const t0 = Date.now();
2046
+ this._log.info(`[PREWARM-T] start call_id=${callId} t=${(t0 / 1e3).toFixed(3)}`);
2047
+ const task = (async () => {
2048
+ let timer;
2049
+ try {
2050
+ const timeout = new Promise((_, reject) => {
2051
+ timer = setTimeout(
2052
+ () => reject(new Error("prewarm timeout")),
2053
+ PREWARM_TIMEOUT_MS
2054
+ );
2055
+ });
2056
+ await Promise.race([sessionHandler.prewarm(), timeout]);
2057
+ const elapsed = Date.now() - t0;
2058
+ this._log.info(`[PREWARM-T] done call_id=${callId} elapsed_ms=${elapsed}`);
2059
+ } catch (err) {
2060
+ const elapsed = Date.now() - t0;
2061
+ const reason = err instanceof Error ? err.message : String(err);
2062
+ this._log.warn(
2063
+ { err, callId },
2064
+ `[PREWARM-T] failed call_id=${callId} elapsed_ms=${elapsed} reason=${reason}`
2065
+ );
2066
+ this._prewarmFailed.add(callId);
2067
+ } finally {
2068
+ if (timer) clearTimeout(timer);
2069
+ }
2070
+ })();
2071
+ this._prewarmTasks.set(callId, task);
2072
+ }
1995
2073
  _handleRinging(event) {
1996
2074
  const callId = event["callId"];
1997
2075
  const session = this._activeSessions.get(callId);
1998
2076
  if (session) {
1999
2077
  this._log.info("Outbound call ringing: %s", callId);
2078
+ if (this._prewarmEnabled) {
2079
+ this._startPrewarm(callId);
2080
+ }
2000
2081
  }
2001
2082
  }
2002
2083
  _handleFailed(event) {
@@ -2008,6 +2089,7 @@ var ClawOpsAgent = class _ClawOpsAgent {
2008
2089
  session._markEnded();
2009
2090
  this._activeSessions.delete(callId);
2010
2091
  }
2092
+ void this._cleanupPrewarm(callId);
2011
2093
  }
2012
2094
  _onDtmfEvent(callSession, digit) {
2013
2095
  callSession._emit("dtmf", digit);
@@ -2160,7 +2242,36 @@ var ClawOpsAgent = class _ClawOpsAgent {
2160
2242
  try {
2161
2243
  await mediaWs.connect(mediaWsUrl, this._apiKey);
2162
2244
  this._log.info("Media stream started: %s", session.callId);
2163
- await sessionHandler.start(session, sessionTools);
2245
+ const prewarmTask = this._prewarmTasks.get(session.callId);
2246
+ if (prewarmTask && !this._prewarmFailed.has(session.callId)) {
2247
+ try {
2248
+ await prewarmTask;
2249
+ if (this._prewarmFailed.has(session.callId)) {
2250
+ await sessionHandler.start(session, sessionTools);
2251
+ } else {
2252
+ this._log.info(
2253
+ `[PREWARM-T] attach call_id=${session.callId} t=${(Date.now() / 1e3).toFixed(3)}`
2254
+ );
2255
+ await sessionHandler.attach(session);
2256
+ this._prewarmAttached.add(session.callId);
2257
+ }
2258
+ } catch (err) {
2259
+ this._log.warn(
2260
+ { err, callId: session.callId },
2261
+ "prewarm/attach failed, falling back to start()"
2262
+ );
2263
+ try {
2264
+ await sessionHandler.stop();
2265
+ } catch {
2266
+ }
2267
+ await sessionHandler.start(session, sessionTools);
2268
+ }
2269
+ } else {
2270
+ await sessionHandler.start(session, sessionTools);
2271
+ }
2272
+ this._prewarmTasks.delete(session.callId);
2273
+ this._prewarmFailed.delete(session.callId);
2274
+ this._prewarmAttached.delete(session.callId);
2164
2275
  const telemetry = sessionHandler.getTelemetry?.() ?? null;
2165
2276
  if (telemetry) {
2166
2277
  telemetry.toolCount = sessionTools?.size ?? 0;
@@ -2349,6 +2460,86 @@ async function executeBuiltinTool(funcName, args, call) {
2349
2460
  return null;
2350
2461
  }
2351
2462
 
2463
+ // src/agent/pipeline/buffering-call.ts
2464
+ var MetricsStub = class {
2465
+ recordToolCall() {
2466
+ }
2467
+ recordInterrupt() {
2468
+ }
2469
+ recordToolError() {
2470
+ }
2471
+ };
2472
+ var BufferingCall = class {
2473
+ _buffer = [];
2474
+ _droppedEvents = {};
2475
+ metrics = new MetricsStub();
2476
+ async sendAudio(chunk) {
2477
+ this._buffer.push(chunk);
2478
+ }
2479
+ /** clearAudio 도 prewarm 동안엔 no-op (드물긴 하지만 안전하게). */
2480
+ clearAudio() {
2481
+ this._buffer = [];
2482
+ }
2483
+ /**
2484
+ * transcript 등 lifecycle 이벤트는 prewarm 동안 무시한다. silent drop 은 디버깅이
2485
+ * 어려우므로 event name 별 카운터로 누적하고 attachBuffered() 시 한 번에 로깅한다.
2486
+ */
2487
+ _emit(...args) {
2488
+ this._recordDropped(args);
2489
+ }
2490
+ async emit(...args) {
2491
+ this._recordDropped(args);
2492
+ }
2493
+ _recordDropped(args) {
2494
+ let eventName = "?";
2495
+ if (args.length > 0 && typeof args[0] === "string") {
2496
+ eventName = args[0];
2497
+ }
2498
+ this._droppedEvents[eventName] = (this._droppedEvents[eventName] ?? 0) + 1;
2499
+ }
2500
+ recordToolCall() {
2501
+ }
2502
+ recordToolError() {
2503
+ }
2504
+ recordFirstResponse() {
2505
+ }
2506
+ recordBargeIn() {
2507
+ }
2508
+ drainBuffer() {
2509
+ const out = this._buffer;
2510
+ this._buffer = [];
2511
+ return out;
2512
+ }
2513
+ drainDroppedEvents() {
2514
+ const out = this._droppedEvents;
2515
+ this._droppedEvents = {};
2516
+ return out;
2517
+ }
2518
+ };
2519
+ function attachBuffered(prev, next) {
2520
+ if (!(prev instanceof BufferingCall)) {
2521
+ return false;
2522
+ }
2523
+ const drained = prev.drainBuffer();
2524
+ const flushed = drained.length > 0;
2525
+ const callId = next.callId ?? "?";
2526
+ if (flushed) {
2527
+ console.info(
2528
+ `[PREWARM-T] first-audio call_id=${callId} t=${(process.hrtime.bigint() / 1000000n).toString()} buffered_chunks=${drained.length} source=prebuffer`
2529
+ );
2530
+ }
2531
+ for (const chunk of drained) {
2532
+ next.sendAudio(chunk);
2533
+ }
2534
+ const dropped = prev.drainDroppedEvents();
2535
+ if (Object.keys(dropped).length > 0) {
2536
+ console.info(
2537
+ `[PREWARM] dropped events during prewarm call_id=${callId} events=${JSON.stringify(dropped)}`
2538
+ );
2539
+ }
2540
+ return flushed;
2541
+ }
2542
+
2352
2543
  // src/agent/pipeline/pipeline-session.ts
2353
2544
  var PipelineSession = class {
2354
2545
  _stt;
@@ -2425,12 +2616,17 @@ var PipelineSession = class {
2425
2616
  this._tts.setLogger(logger);
2426
2617
  }
2427
2618
  }
2428
- async start(callSession, tools) {
2429
- this._callSession = callSession;
2430
- this._tools = tools ?? null;
2619
+ /**
2620
+ * Pre-bootstrap conversation state and (optionally) trigger greeting synthesis
2621
+ * before a real CallSession is attached. Audio chunks are buffered into a
2622
+ * BufferingCall until attach() flushes them.
2623
+ */
2624
+ async prewarm(tools) {
2625
+ if (tools) this._tools = tools;
2626
+ this._callSession = new BufferingCall();
2431
2627
  this._running = true;
2432
- this._log.info("PipelineSession started");
2433
2628
  this._conversation = [];
2629
+ this._log.info("PipelineSession prewarmed");
2434
2630
  if (this._systemPrompt) {
2435
2631
  this._conversation.push({
2436
2632
  role: "system",
@@ -2446,6 +2642,17 @@ var PipelineSession = class {
2446
2642
  this._log.error({ err }, "STT loop error");
2447
2643
  });
2448
2644
  }
2645
+ /** Attach a real CallSession to the prewarmed session and flush buffered audio. */
2646
+ async attach(callSession) {
2647
+ const prev = this._callSession;
2648
+ this._callSession = callSession;
2649
+ attachBuffered(prev, callSession);
2650
+ }
2651
+ async start(callSession, tools) {
2652
+ this._tools = tools ?? null;
2653
+ await this.prewarm();
2654
+ await this.attach(callSession);
2655
+ }
2449
2656
  feedAudio(audio) {
2450
2657
  if (this._running) {
2451
2658
  this._audioBuffer.push(audio);
@@ -2552,7 +2759,7 @@ var PipelineSession = class {
2552
2759
  const { id, name, arguments: argsStr } = chunk.toolCall;
2553
2760
  try {
2554
2761
  const args = JSON.parse(argsStr);
2555
- if (BUILTIN_TOOL_NAMES.has(name) && this._callSession) {
2762
+ if (BUILTIN_TOOL_NAMES.has(name) && this._callSession && !(this._callSession instanceof BufferingCall)) {
2556
2763
  const result2 = await executeBuiltinTool(name, args, this._callSession);
2557
2764
  if (result2 !== null) {
2558
2765
  if (name === "hang_up") return;
@@ -2563,7 +2770,7 @@ var PipelineSession = class {
2563
2770
  }
2564
2771
  if (!this._tools) return;
2565
2772
  this._callSession?.recordToolCall();
2566
- const player = this._holdAudioChunks && this._callSession ? new HoldAudioPlayer(this._callSession, this._holdAudioChunks) : null;
2773
+ const player = this._holdAudioChunks && this._callSession && !(this._callSession instanceof BufferingCall) ? new HoldAudioPlayer(this._callSession, this._holdAudioChunks) : null;
2567
2774
  player?.start();
2568
2775
  let result;
2569
2776
  try {
@@ -2706,24 +2913,23 @@ var OpenAIRealtime = class {
2706
2913
  setHoldAudio(chunks) {
2707
2914
  this._holdAudioChunks = chunks;
2708
2915
  }
2709
- async start(callSession, tools) {
2710
- this._call = callSession;
2916
+ /** Open WS + session.update + (optional) response.create without a CallSession. */
2917
+ async prewarm(tools) {
2711
2918
  if (tools) this._tools = tools;
2919
+ this._call = new BufferingCall();
2712
2920
  this._closed = false;
2713
2921
  this._playback = null;
2714
2922
  this._latestMediaTs = 0;
2715
2923
  const { WebSocket } = await import('ws');
2716
2924
  const url = `${OPENAI_REALTIME_URL}${this._model}`;
2717
2925
  this._ws = new WebSocket(url, {
2718
- headers: {
2719
- Authorization: `Bearer ${this._apiKey}`
2720
- }
2926
+ headers: { Authorization: `Bearer ${this._apiKey}` }
2721
2927
  });
2722
2928
  return new Promise((resolve, reject) => {
2723
2929
  const ws = this._ws;
2724
2930
  ws.on("open", () => {
2725
2931
  this._sendSessionUpdate();
2726
- this._log.info("OpenAI Realtime connected");
2932
+ this._log.info("OpenAI Realtime connected (prewarm)");
2727
2933
  if (this._greeting) {
2728
2934
  this._send({ type: "response.create" });
2729
2935
  }
@@ -2747,6 +2953,17 @@ var OpenAIRealtime = class {
2747
2953
  });
2748
2954
  });
2749
2955
  }
2956
+ /** Attach a real CallSession to the prewarmed session and flush buffered audio. */
2957
+ async attach(callSession) {
2958
+ const prev = this._call;
2959
+ this._call = callSession;
2960
+ attachBuffered(prev, callSession);
2961
+ }
2962
+ async start(callSession, tools) {
2963
+ if (tools) this._tools = tools;
2964
+ await this.prewarm();
2965
+ await this.attach(callSession);
2966
+ }
2750
2967
  async feedDtmf(digits) {
2751
2968
  await this._waitForResponseDone();
2752
2969
  this._send({
@@ -2775,8 +2992,27 @@ var OpenAIRealtime = class {
2775
2992
  }
2776
2993
  this._pendingToolCalls.clear();
2777
2994
  if (this._ws) {
2778
- this._ws.close();
2995
+ const ws = this._ws;
2779
2996
  this._ws = null;
2997
+ await new Promise((resolve) => {
2998
+ let done = false;
2999
+ const finish = () => {
3000
+ if (done) return;
3001
+ done = true;
3002
+ resolve();
3003
+ };
3004
+ const timer = setTimeout(finish, 2e3);
3005
+ try {
3006
+ ws.on("close", () => {
3007
+ clearTimeout(timer);
3008
+ finish();
3009
+ });
3010
+ ws.close();
3011
+ } catch {
3012
+ clearTimeout(timer);
3013
+ finish();
3014
+ }
3015
+ });
2780
3016
  }
2781
3017
  }
2782
3018
  _sendSessionUpdate() {
@@ -2927,7 +3163,7 @@ var OpenAIRealtime = class {
2927
3163
  const controller = new AbortController();
2928
3164
  this._pendingToolCalls.set(callId, controller);
2929
3165
  try {
2930
- if (BUILTIN_TOOL_NAMES.has(funcName) && this._call) {
3166
+ if (BUILTIN_TOOL_NAMES.has(funcName) && this._call && !(this._call instanceof BufferingCall)) {
2931
3167
  const args = JSON.parse(item["arguments"] ?? "{}");
2932
3168
  const result2 = await executeBuiltinTool(funcName, args, this._call);
2933
3169
  if (result2 !== null) {
@@ -2961,7 +3197,7 @@ var OpenAIRealtime = class {
2961
3197
  return;
2962
3198
  }
2963
3199
  let result;
2964
- const player = this._holdAudioChunks && this._call ? new HoldAudioPlayer(this._call, this._holdAudioChunks) : null;
3200
+ const player = this._holdAudioChunks && this._call && !(this._call instanceof BufferingCall) ? new HoldAudioPlayer(this._call, this._holdAudioChunks) : null;
2965
3201
  player?.start();
2966
3202
  try {
2967
3203
  const args = JSON.parse(item["arguments"] ?? "{}");
@@ -3146,9 +3382,10 @@ var GeminiRealtime = class _GeminiRealtime {
3146
3382
  builtinTools: []
3147
3383
  };
3148
3384
  }
3149
- async start(callSession, tools) {
3150
- this._call = callSession;
3385
+ /** Open Live session (no CallSession). Audio deltas accumulate into BufferingCall until attach(). */
3386
+ async prewarm(tools) {
3151
3387
  if (tools) this._tools = tools;
3388
+ this._call = new BufferingCall();
3152
3389
  this._closed = false;
3153
3390
  this._sentAudioChunks = 0;
3154
3391
  this._audioRemainder = Buffer.alloc(0);
@@ -3196,10 +3433,22 @@ var GeminiRealtime = class _GeminiRealtime {
3196
3433
  }
3197
3434
  }
3198
3435
  });
3436
+ this._log.info("Gemini Live connected (prewarm)");
3199
3437
  if (this._greeting) {
3200
3438
  this._session.sendRealtimeInput({ text: "\uC778\uC0AC\uD574 \uC8FC\uC138\uC694." });
3201
3439
  }
3202
3440
  }
3441
+ /** Attach a real CallSession to the prewarmed session and flush buffered audio. */
3442
+ async attach(callSession) {
3443
+ const prev = this._call;
3444
+ this._call = callSession;
3445
+ attachBuffered(prev, callSession);
3446
+ }
3447
+ async start(callSession, tools) {
3448
+ if (tools) this._tools = tools;
3449
+ await this.prewarm();
3450
+ await this.attach(callSession);
3451
+ }
3203
3452
  feedAudio(audio) {
3204
3453
  if (this._session && !this._closed) {
3205
3454
  const pcm8k = ulawToPcm16(audio);
@@ -3225,11 +3474,18 @@ var GeminiRealtime = class _GeminiRealtime {
3225
3474
  }
3226
3475
  this._pendingToolCall = null;
3227
3476
  if (this._session) {
3228
- try {
3229
- this._session.close();
3230
- } catch {
3231
- }
3477
+ const sess = this._session;
3232
3478
  this._session = null;
3479
+ await Promise.race([
3480
+ (async () => {
3481
+ try {
3482
+ const ret = sess.close();
3483
+ if (ret && typeof ret.then === "function") await ret;
3484
+ } catch {
3485
+ }
3486
+ })(),
3487
+ new Promise((resolve) => setTimeout(resolve, 2e3))
3488
+ ]);
3233
3489
  }
3234
3490
  }
3235
3491
  _buildToolSchemas() {
@@ -3344,7 +3600,7 @@ var GeminiRealtime = class _GeminiRealtime {
3344
3600
  const functionCalls = toolCall.functionCalls;
3345
3601
  if (!functionCalls) return;
3346
3602
  const responses = [];
3347
- const player = this._holdAudioChunks && this._call ? new HoldAudioPlayer(this._call, this._holdAudioChunks) : null;
3603
+ const player = this._holdAudioChunks && this._call && !(this._call instanceof BufferingCall) ? new HoldAudioPlayer(this._call, this._holdAudioChunks) : null;
3348
3604
  player?.start();
3349
3605
  try {
3350
3606
  for (const fc of functionCalls) {
@@ -3352,7 +3608,7 @@ var GeminiRealtime = class _GeminiRealtime {
3352
3608
  const fcId = fc.id ?? "";
3353
3609
  const args = fc.args ?? {};
3354
3610
  this._log.info({ tool: name, args }, "Tool call: %s", name);
3355
- if (BUILTIN_TOOL_NAMES.has(name) && this._call) {
3611
+ if (BUILTIN_TOOL_NAMES.has(name) && this._call && !(this._call instanceof BufferingCall)) {
3356
3612
  const result = await executeBuiltinTool(
3357
3613
  name,
3358
3614
  args,
@@ -4287,6 +4543,7 @@ exports.BUILTIN_TOOL_NAMES = BUILTIN_TOOL_NAMES;
4287
4543
  exports.BuiltinTool = BuiltinTool;
4288
4544
  exports.CallSession = CallSession;
4289
4545
  exports.ClawOpsAgent = ClawOpsAgent;
4546
+ exports.ControlWebSocket = ControlWebSocket;
4290
4547
  exports.DECODE_TABLE = DECODE_TABLE;
4291
4548
  exports.DeepSeekLLM = DeepSeekLLM;
4292
4549
  exports.DeepgramSTT = DeepgramSTT;
@@ -4307,6 +4564,7 @@ exports.PipelineSession = PipelineSession;
4307
4564
  exports.TogetherLLM = TogetherLLM;
4308
4565
  exports.ToolRegistry = ToolRegistry;
4309
4566
  exports.XaiLLM = XaiLLM;
4567
+ exports.buildControlWsUrl = buildControlWsUrl;
4310
4568
  exports.createAgentLogger = createAgentLogger;
4311
4569
  exports.createPipelineLogger = createPipelineLogger;
4312
4570
  exports.executeBuiltinTool = executeBuiltinTool;