@teamlearners/clawops 0.16.6 → 0.17.1

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.
@@ -1,4 +1,4 @@
1
- import { DEFAULT_BASE_URL, AgentError, AgentConnectionError, VERSION } from '../chunk-RYJF2N7K.js';
1
+ import { DEFAULT_BASE_URL, AgentError, AgentConnectionError, VERSION } from '../chunk-LYFVJLIS.js';
2
2
  import pino from 'pino';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
@@ -1732,6 +1732,11 @@ var ClawOpsAgent = class _ClawOpsAgent {
1732
1732
  _holdAudioChunks = null;
1733
1733
  _rxGain;
1734
1734
  _txGain;
1735
+ _prewarmTasks = /* @__PURE__ */ new Map();
1736
+ _prewarmFailed = /* @__PURE__ */ new Set();
1737
+ /** prewarm 세션이 실제 CallSession 에 attach 완료된 callId. attached 이후의 stop() 은 정상 종료 경로가 책임진다. */
1738
+ _prewarmAttached = /* @__PURE__ */ new Set();
1739
+ _prewarmEnabled;
1735
1740
  constructor(options) {
1736
1741
  this._apiKey = options.apiKey ?? process.env["CLAWOPS_API_KEY"] ?? "";
1737
1742
  this._accountId = options.accountId ?? process.env["CLAWOPS_ACCOUNT_ID"] ?? "";
@@ -1745,6 +1750,7 @@ var ClawOpsAgent = class _ClawOpsAgent {
1745
1750
  this._passiveDtmfDebounceMs = options.passiveDtmfDebounceMs ?? 500;
1746
1751
  this._rxGain = _ClawOpsAgent._validateGain("rxGain", options.rxGain ?? 1);
1747
1752
  this._txGain = _ClawOpsAgent._validateGain("txGain", options.txGain ?? 1);
1753
+ this._prewarmEnabled = options.prewarmEnabled ?? true;
1748
1754
  if (options.tracing) {
1749
1755
  setTracingConfig(options.tracing);
1750
1756
  }
@@ -1903,6 +1909,9 @@ var ClawOpsAgent = class _ClawOpsAgent {
1903
1909
  callSession.setLogger(this._log);
1904
1910
  this._activeSessions.set(callSession.callId, callSession);
1905
1911
  this._log.info("Outbound call initiated: %s -> %s (%s)", this._fromNumber, to, callSession.callId);
1912
+ if (this._prewarmEnabled) {
1913
+ this._startPrewarm(callSession.callId);
1914
+ }
1906
1915
  return callSession;
1907
1916
  }
1908
1917
  _handleIncoming(event) {
@@ -1939,6 +1948,34 @@ var ClawOpsAgent = class _ClawOpsAgent {
1939
1948
  session._markEnded();
1940
1949
  this._activeSessions.delete(callId);
1941
1950
  }
1951
+ void this._cleanupPrewarm(callId);
1952
+ }
1953
+ /**
1954
+ * Drop prewarm bookkeeping for a callId. Used on hangup/failure paths.
1955
+ *
1956
+ * prewarm 이 진행 중이거나 완료됐지만 attach 전에 호출되면 LLM WS 가 leak 되므로
1957
+ * race 후 session.stop() 으로 정리한다. (TS 에는 Promise.cancel 이 없어 Python
1958
+ * 의 task.cancel() 등가물은 _session.stop() 호출이다.)
1959
+ *
1960
+ * 이미 attach 된 callId 면 stop() 을 호출하지 않는다 — 정상 종료 경로 (call-session
1961
+ * finally) 가 책임지기 때문이다.
1962
+ */
1963
+ async _cleanupPrewarm(callId) {
1964
+ const task = this._prewarmTasks.get(callId);
1965
+ const attached = this._prewarmAttached.has(callId);
1966
+ this._prewarmTasks.delete(callId);
1967
+ this._prewarmFailed.delete(callId);
1968
+ this._prewarmAttached.delete(callId);
1969
+ if (!task || attached) return;
1970
+ try {
1971
+ await task;
1972
+ } catch {
1973
+ }
1974
+ try {
1975
+ await this._session.stop();
1976
+ } catch (err) {
1977
+ this._log.warn({ err, callId }, "prewarm cleanup stop() failed");
1978
+ }
1942
1979
  }
1943
1980
  _handleOutboundReady(event) {
1944
1981
  const callId = event["callId"];
@@ -1960,16 +1997,60 @@ var ClawOpsAgent = class _ClawOpsAgent {
1960
1997
  }
1961
1998
  this._activeSessions.set(callId, session);
1962
1999
  }
2000
+ if (this._prewarmEnabled) {
2001
+ this._startPrewarm(callId);
2002
+ }
1963
2003
  if (mediaUrl) {
1964
2004
  this._log.info("Outbound call answered: %s -> %s (%s)", this._fromNumber, session.toNumber, callId);
1965
2005
  this._safeStartCallSession(session, mediaUrl, callId);
1966
2006
  }
1967
2007
  }
2008
+ /**
2009
+ * Start the LLM session prewarm task for the given callId. Safe to call
2010
+ * multiple times — only the first invocation starts the task. Failures are
2011
+ * recorded in _prewarmFailed so the call-session path can fall back to start().
2012
+ */
2013
+ _startPrewarm(callId) {
2014
+ if (this._prewarmTasks.has(callId)) return;
2015
+ const sessionHandler = this._session;
2016
+ if (typeof sessionHandler.prewarm !== "function") return;
2017
+ const PREWARM_TIMEOUT_MS = 1e4;
2018
+ const t0 = Date.now();
2019
+ this._log.info(`[PREWARM-T] start call_id=${callId} t=${(t0 / 1e3).toFixed(3)}`);
2020
+ const task = (async () => {
2021
+ let timer;
2022
+ try {
2023
+ const timeout = new Promise((_, reject) => {
2024
+ timer = setTimeout(
2025
+ () => reject(new Error("prewarm timeout")),
2026
+ PREWARM_TIMEOUT_MS
2027
+ );
2028
+ });
2029
+ await Promise.race([sessionHandler.prewarm(), timeout]);
2030
+ const elapsed = Date.now() - t0;
2031
+ this._log.info(`[PREWARM-T] done call_id=${callId} elapsed_ms=${elapsed}`);
2032
+ } catch (err) {
2033
+ const elapsed = Date.now() - t0;
2034
+ const reason = err instanceof Error ? err.message : String(err);
2035
+ this._log.warn(
2036
+ { err, callId },
2037
+ `[PREWARM-T] failed call_id=${callId} elapsed_ms=${elapsed} reason=${reason}`
2038
+ );
2039
+ this._prewarmFailed.add(callId);
2040
+ } finally {
2041
+ if (timer) clearTimeout(timer);
2042
+ }
2043
+ })();
2044
+ this._prewarmTasks.set(callId, task);
2045
+ }
1968
2046
  _handleRinging(event) {
1969
2047
  const callId = event["callId"];
1970
2048
  const session = this._activeSessions.get(callId);
1971
2049
  if (session) {
1972
2050
  this._log.info("Outbound call ringing: %s", callId);
2051
+ if (this._prewarmEnabled) {
2052
+ this._startPrewarm(callId);
2053
+ }
1973
2054
  }
1974
2055
  }
1975
2056
  _handleFailed(event) {
@@ -1981,6 +2062,7 @@ var ClawOpsAgent = class _ClawOpsAgent {
1981
2062
  session._markEnded();
1982
2063
  this._activeSessions.delete(callId);
1983
2064
  }
2065
+ void this._cleanupPrewarm(callId);
1984
2066
  }
1985
2067
  _onDtmfEvent(callSession, digit) {
1986
2068
  callSession._emit("dtmf", digit);
@@ -2133,7 +2215,36 @@ var ClawOpsAgent = class _ClawOpsAgent {
2133
2215
  try {
2134
2216
  await mediaWs.connect(mediaWsUrl, this._apiKey);
2135
2217
  this._log.info("Media stream started: %s", session.callId);
2136
- await sessionHandler.start(session, sessionTools);
2218
+ const prewarmTask = this._prewarmTasks.get(session.callId);
2219
+ if (prewarmTask && !this._prewarmFailed.has(session.callId)) {
2220
+ try {
2221
+ await prewarmTask;
2222
+ if (this._prewarmFailed.has(session.callId)) {
2223
+ await sessionHandler.start(session, sessionTools);
2224
+ } else {
2225
+ this._log.info(
2226
+ `[PREWARM-T] attach call_id=${session.callId} t=${(Date.now() / 1e3).toFixed(3)}`
2227
+ );
2228
+ await sessionHandler.attach(session);
2229
+ this._prewarmAttached.add(session.callId);
2230
+ }
2231
+ } catch (err) {
2232
+ this._log.warn(
2233
+ { err, callId: session.callId },
2234
+ "prewarm/attach failed, falling back to start()"
2235
+ );
2236
+ try {
2237
+ await sessionHandler.stop();
2238
+ } catch {
2239
+ }
2240
+ await sessionHandler.start(session, sessionTools);
2241
+ }
2242
+ } else {
2243
+ await sessionHandler.start(session, sessionTools);
2244
+ }
2245
+ this._prewarmTasks.delete(session.callId);
2246
+ this._prewarmFailed.delete(session.callId);
2247
+ this._prewarmAttached.delete(session.callId);
2137
2248
  const telemetry = sessionHandler.getTelemetry?.() ?? null;
2138
2249
  if (telemetry) {
2139
2250
  telemetry.toolCount = sessionTools?.size ?? 0;
@@ -2322,6 +2433,86 @@ async function executeBuiltinTool(funcName, args, call) {
2322
2433
  return null;
2323
2434
  }
2324
2435
 
2436
+ // src/agent/pipeline/buffering-call.ts
2437
+ var MetricsStub = class {
2438
+ recordToolCall() {
2439
+ }
2440
+ recordInterrupt() {
2441
+ }
2442
+ recordToolError() {
2443
+ }
2444
+ };
2445
+ var BufferingCall = class {
2446
+ _buffer = [];
2447
+ _droppedEvents = {};
2448
+ metrics = new MetricsStub();
2449
+ async sendAudio(chunk) {
2450
+ this._buffer.push(chunk);
2451
+ }
2452
+ /** clearAudio 도 prewarm 동안엔 no-op (드물긴 하지만 안전하게). */
2453
+ clearAudio() {
2454
+ this._buffer = [];
2455
+ }
2456
+ /**
2457
+ * transcript 등 lifecycle 이벤트는 prewarm 동안 무시한다. silent drop 은 디버깅이
2458
+ * 어려우므로 event name 별 카운터로 누적하고 attachBuffered() 시 한 번에 로깅한다.
2459
+ */
2460
+ _emit(...args) {
2461
+ this._recordDropped(args);
2462
+ }
2463
+ async emit(...args) {
2464
+ this._recordDropped(args);
2465
+ }
2466
+ _recordDropped(args) {
2467
+ let eventName = "?";
2468
+ if (args.length > 0 && typeof args[0] === "string") {
2469
+ eventName = args[0];
2470
+ }
2471
+ this._droppedEvents[eventName] = (this._droppedEvents[eventName] ?? 0) + 1;
2472
+ }
2473
+ recordToolCall() {
2474
+ }
2475
+ recordToolError() {
2476
+ }
2477
+ recordFirstResponse() {
2478
+ }
2479
+ recordBargeIn() {
2480
+ }
2481
+ drainBuffer() {
2482
+ const out = this._buffer;
2483
+ this._buffer = [];
2484
+ return out;
2485
+ }
2486
+ drainDroppedEvents() {
2487
+ const out = this._droppedEvents;
2488
+ this._droppedEvents = {};
2489
+ return out;
2490
+ }
2491
+ };
2492
+ function attachBuffered(prev, next) {
2493
+ if (!(prev instanceof BufferingCall)) {
2494
+ return false;
2495
+ }
2496
+ const drained = prev.drainBuffer();
2497
+ const flushed = drained.length > 0;
2498
+ const callId = next.callId ?? "?";
2499
+ if (flushed) {
2500
+ console.info(
2501
+ `[PREWARM-T] first-audio call_id=${callId} t=${(process.hrtime.bigint() / 1000000n).toString()} buffered_chunks=${drained.length} source=prebuffer`
2502
+ );
2503
+ }
2504
+ for (const chunk of drained) {
2505
+ next.sendAudio(chunk);
2506
+ }
2507
+ const dropped = prev.drainDroppedEvents();
2508
+ if (Object.keys(dropped).length > 0) {
2509
+ console.info(
2510
+ `[PREWARM] dropped events during prewarm call_id=${callId} events=${JSON.stringify(dropped)}`
2511
+ );
2512
+ }
2513
+ return flushed;
2514
+ }
2515
+
2325
2516
  // src/agent/pipeline/pipeline-session.ts
2326
2517
  var PipelineSession = class {
2327
2518
  _stt;
@@ -2398,12 +2589,17 @@ var PipelineSession = class {
2398
2589
  this._tts.setLogger(logger);
2399
2590
  }
2400
2591
  }
2401
- async start(callSession, tools) {
2402
- this._callSession = callSession;
2403
- this._tools = tools ?? null;
2592
+ /**
2593
+ * Pre-bootstrap conversation state and (optionally) trigger greeting synthesis
2594
+ * before a real CallSession is attached. Audio chunks are buffered into a
2595
+ * BufferingCall until attach() flushes them.
2596
+ */
2597
+ async prewarm(tools) {
2598
+ if (tools) this._tools = tools;
2599
+ this._callSession = new BufferingCall();
2404
2600
  this._running = true;
2405
- this._log.info("PipelineSession started");
2406
2601
  this._conversation = [];
2602
+ this._log.info("PipelineSession prewarmed");
2407
2603
  if (this._systemPrompt) {
2408
2604
  this._conversation.push({
2409
2605
  role: "system",
@@ -2419,6 +2615,17 @@ var PipelineSession = class {
2419
2615
  this._log.error({ err }, "STT loop error");
2420
2616
  });
2421
2617
  }
2618
+ /** Attach a real CallSession to the prewarmed session and flush buffered audio. */
2619
+ async attach(callSession) {
2620
+ const prev = this._callSession;
2621
+ this._callSession = callSession;
2622
+ attachBuffered(prev, callSession);
2623
+ }
2624
+ async start(callSession, tools) {
2625
+ this._tools = tools ?? null;
2626
+ await this.prewarm();
2627
+ await this.attach(callSession);
2628
+ }
2422
2629
  feedAudio(audio) {
2423
2630
  if (this._running) {
2424
2631
  this._audioBuffer.push(audio);
@@ -2525,7 +2732,7 @@ var PipelineSession = class {
2525
2732
  const { id, name, arguments: argsStr } = chunk.toolCall;
2526
2733
  try {
2527
2734
  const args = JSON.parse(argsStr);
2528
- if (BUILTIN_TOOL_NAMES.has(name) && this._callSession) {
2735
+ if (BUILTIN_TOOL_NAMES.has(name) && this._callSession && !(this._callSession instanceof BufferingCall)) {
2529
2736
  const result2 = await executeBuiltinTool(name, args, this._callSession);
2530
2737
  if (result2 !== null) {
2531
2738
  if (name === "hang_up") return;
@@ -2536,7 +2743,7 @@ var PipelineSession = class {
2536
2743
  }
2537
2744
  if (!this._tools) return;
2538
2745
  this._callSession?.recordToolCall();
2539
- const player = this._holdAudioChunks && this._callSession ? new HoldAudioPlayer(this._callSession, this._holdAudioChunks) : null;
2746
+ const player = this._holdAudioChunks && this._callSession && !(this._callSession instanceof BufferingCall) ? new HoldAudioPlayer(this._callSession, this._holdAudioChunks) : null;
2540
2747
  player?.start();
2541
2748
  let result;
2542
2749
  try {
@@ -2679,24 +2886,23 @@ var OpenAIRealtime = class {
2679
2886
  setHoldAudio(chunks) {
2680
2887
  this._holdAudioChunks = chunks;
2681
2888
  }
2682
- async start(callSession, tools) {
2683
- this._call = callSession;
2889
+ /** Open WS + session.update + (optional) response.create without a CallSession. */
2890
+ async prewarm(tools) {
2684
2891
  if (tools) this._tools = tools;
2892
+ this._call = new BufferingCall();
2685
2893
  this._closed = false;
2686
2894
  this._playback = null;
2687
2895
  this._latestMediaTs = 0;
2688
2896
  const { WebSocket } = await import('ws');
2689
2897
  const url = `${OPENAI_REALTIME_URL}${this._model}`;
2690
2898
  this._ws = new WebSocket(url, {
2691
- headers: {
2692
- Authorization: `Bearer ${this._apiKey}`
2693
- }
2899
+ headers: { Authorization: `Bearer ${this._apiKey}` }
2694
2900
  });
2695
2901
  return new Promise((resolve, reject) => {
2696
2902
  const ws = this._ws;
2697
2903
  ws.on("open", () => {
2698
2904
  this._sendSessionUpdate();
2699
- this._log.info("OpenAI Realtime connected");
2905
+ this._log.info("OpenAI Realtime connected (prewarm)");
2700
2906
  if (this._greeting) {
2701
2907
  this._send({ type: "response.create" });
2702
2908
  }
@@ -2720,6 +2926,17 @@ var OpenAIRealtime = class {
2720
2926
  });
2721
2927
  });
2722
2928
  }
2929
+ /** Attach a real CallSession to the prewarmed session and flush buffered audio. */
2930
+ async attach(callSession) {
2931
+ const prev = this._call;
2932
+ this._call = callSession;
2933
+ attachBuffered(prev, callSession);
2934
+ }
2935
+ async start(callSession, tools) {
2936
+ if (tools) this._tools = tools;
2937
+ await this.prewarm();
2938
+ await this.attach(callSession);
2939
+ }
2723
2940
  async feedDtmf(digits) {
2724
2941
  await this._waitForResponseDone();
2725
2942
  this._send({
@@ -2748,8 +2965,27 @@ var OpenAIRealtime = class {
2748
2965
  }
2749
2966
  this._pendingToolCalls.clear();
2750
2967
  if (this._ws) {
2751
- this._ws.close();
2968
+ const ws = this._ws;
2752
2969
  this._ws = null;
2970
+ await new Promise((resolve) => {
2971
+ let done = false;
2972
+ const finish = () => {
2973
+ if (done) return;
2974
+ done = true;
2975
+ resolve();
2976
+ };
2977
+ const timer = setTimeout(finish, 2e3);
2978
+ try {
2979
+ ws.on("close", () => {
2980
+ clearTimeout(timer);
2981
+ finish();
2982
+ });
2983
+ ws.close();
2984
+ } catch {
2985
+ clearTimeout(timer);
2986
+ finish();
2987
+ }
2988
+ });
2753
2989
  }
2754
2990
  }
2755
2991
  _sendSessionUpdate() {
@@ -2900,7 +3136,7 @@ var OpenAIRealtime = class {
2900
3136
  const controller = new AbortController();
2901
3137
  this._pendingToolCalls.set(callId, controller);
2902
3138
  try {
2903
- if (BUILTIN_TOOL_NAMES.has(funcName) && this._call) {
3139
+ if (BUILTIN_TOOL_NAMES.has(funcName) && this._call && !(this._call instanceof BufferingCall)) {
2904
3140
  const args = JSON.parse(item["arguments"] ?? "{}");
2905
3141
  const result2 = await executeBuiltinTool(funcName, args, this._call);
2906
3142
  if (result2 !== null) {
@@ -2934,7 +3170,7 @@ var OpenAIRealtime = class {
2934
3170
  return;
2935
3171
  }
2936
3172
  let result;
2937
- const player = this._holdAudioChunks && this._call ? new HoldAudioPlayer(this._call, this._holdAudioChunks) : null;
3173
+ const player = this._holdAudioChunks && this._call && !(this._call instanceof BufferingCall) ? new HoldAudioPlayer(this._call, this._holdAudioChunks) : null;
2938
3174
  player?.start();
2939
3175
  try {
2940
3176
  const args = JSON.parse(item["arguments"] ?? "{}");
@@ -3119,9 +3355,10 @@ var GeminiRealtime = class _GeminiRealtime {
3119
3355
  builtinTools: []
3120
3356
  };
3121
3357
  }
3122
- async start(callSession, tools) {
3123
- this._call = callSession;
3358
+ /** Open Live session (no CallSession). Audio deltas accumulate into BufferingCall until attach(). */
3359
+ async prewarm(tools) {
3124
3360
  if (tools) this._tools = tools;
3361
+ this._call = new BufferingCall();
3125
3362
  this._closed = false;
3126
3363
  this._sentAudioChunks = 0;
3127
3364
  this._audioRemainder = Buffer.alloc(0);
@@ -3169,10 +3406,22 @@ var GeminiRealtime = class _GeminiRealtime {
3169
3406
  }
3170
3407
  }
3171
3408
  });
3409
+ this._log.info("Gemini Live connected (prewarm)");
3172
3410
  if (this._greeting) {
3173
3411
  this._session.sendRealtimeInput({ text: "\uC778\uC0AC\uD574 \uC8FC\uC138\uC694." });
3174
3412
  }
3175
3413
  }
3414
+ /** Attach a real CallSession to the prewarmed session and flush buffered audio. */
3415
+ async attach(callSession) {
3416
+ const prev = this._call;
3417
+ this._call = callSession;
3418
+ attachBuffered(prev, callSession);
3419
+ }
3420
+ async start(callSession, tools) {
3421
+ if (tools) this._tools = tools;
3422
+ await this.prewarm();
3423
+ await this.attach(callSession);
3424
+ }
3176
3425
  feedAudio(audio) {
3177
3426
  if (this._session && !this._closed) {
3178
3427
  const pcm8k = ulawToPcm16(audio);
@@ -3198,11 +3447,18 @@ var GeminiRealtime = class _GeminiRealtime {
3198
3447
  }
3199
3448
  this._pendingToolCall = null;
3200
3449
  if (this._session) {
3201
- try {
3202
- this._session.close();
3203
- } catch {
3204
- }
3450
+ const sess = this._session;
3205
3451
  this._session = null;
3452
+ await Promise.race([
3453
+ (async () => {
3454
+ try {
3455
+ const ret = sess.close();
3456
+ if (ret && typeof ret.then === "function") await ret;
3457
+ } catch {
3458
+ }
3459
+ })(),
3460
+ new Promise((resolve) => setTimeout(resolve, 2e3))
3461
+ ]);
3206
3462
  }
3207
3463
  }
3208
3464
  _buildToolSchemas() {
@@ -3317,7 +3573,7 @@ var GeminiRealtime = class _GeminiRealtime {
3317
3573
  const functionCalls = toolCall.functionCalls;
3318
3574
  if (!functionCalls) return;
3319
3575
  const responses = [];
3320
- const player = this._holdAudioChunks && this._call ? new HoldAudioPlayer(this._call, this._holdAudioChunks) : null;
3576
+ const player = this._holdAudioChunks && this._call && !(this._call instanceof BufferingCall) ? new HoldAudioPlayer(this._call, this._holdAudioChunks) : null;
3321
3577
  player?.start();
3322
3578
  try {
3323
3579
  for (const fc of functionCalls) {
@@ -3325,7 +3581,7 @@ var GeminiRealtime = class _GeminiRealtime {
3325
3581
  const fcId = fc.id ?? "";
3326
3582
  const args = fc.args ?? {};
3327
3583
  this._log.info({ tool: name, args }, "Tool call: %s", name);
3328
- if (BUILTIN_TOOL_NAMES.has(name) && this._call) {
3584
+ if (BUILTIN_TOOL_NAMES.has(name) && this._call && !(this._call instanceof BufferingCall)) {
3329
3585
  const result = await executeBuiltinTool(
3330
3586
  name,
3331
3587
  args,