@teamlearners/clawops 0.8.1 → 0.11.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.
@@ -1,4 +1,4 @@
1
- import { DEFAULT_BASE_URL, AgentError, AgentConnectionError, VERSION } from '../chunk-RUVY7MYW.js';
1
+ import { DEFAULT_BASE_URL, AgentError, AgentConnectionError, VERSION } from '../chunk-5IY2ZR3F.js';
2
2
  import pino from 'pino';
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
@@ -357,6 +357,7 @@ var ControlWebSocket = class {
357
357
  _url;
358
358
  _ws = null;
359
359
  _handlers = /* @__PURE__ */ new Map();
360
+ _transferResolvers = /* @__PURE__ */ new Map();
360
361
  _reconnectDelay = INITIAL_RECONNECT_DELAY;
361
362
  _closed = false;
362
363
  _connectedResolve = null;
@@ -384,6 +385,31 @@ var ControlWebSocket = class {
384
385
  async waitConnected() {
385
386
  return this._connectedPromise;
386
387
  }
388
+ /** Request a call transfer and wait for the result. */
389
+ async requestTransfer(callId, params) {
390
+ return new Promise((resolve, reject) => {
391
+ const timeout = (params.timeout || 30) + 10;
392
+ const timer = setTimeout(() => {
393
+ this._transferResolvers.delete(callId);
394
+ reject(new Error("transfer timeout"));
395
+ }, timeout * 1e3);
396
+ this._transferResolvers.set(callId, {
397
+ resolve: (value) => {
398
+ clearTimeout(timer);
399
+ resolve(value);
400
+ },
401
+ reject: (reason) => {
402
+ clearTimeout(timer);
403
+ reject(reason);
404
+ }
405
+ });
406
+ this.send({
407
+ event: "call.transfer",
408
+ callId,
409
+ transfer: params
410
+ });
411
+ });
412
+ }
387
413
  /** Send a JSON message over the control WebSocket. */
388
414
  send(message) {
389
415
  if (this._ws && this._ws.readyState === 1) {
@@ -394,6 +420,10 @@ var ControlWebSocket = class {
394
420
  close() {
395
421
  this._closed = true;
396
422
  this._clearPingTimer();
423
+ for (const [, resolver] of this._transferResolvers) {
424
+ resolver.reject(new Error("connection closed"));
425
+ }
426
+ this._transferResolvers.clear();
397
427
  if (this._ws) {
398
428
  this._ws.close();
399
429
  this._ws = null;
@@ -439,6 +469,14 @@ var ControlWebSocket = class {
439
469
  });
440
470
  }
441
471
  _dispatchEvent(event) {
472
+ if (["call.transfer.completed", "call.transfer.failed"].includes(event.event)) {
473
+ const callId = event.callId;
474
+ const resolver = this._transferResolvers.get(callId);
475
+ if (resolver) {
476
+ this._transferResolvers.delete(callId);
477
+ resolver.resolve(event.transfer || {});
478
+ }
479
+ }
442
480
  const handlers = this._handlers.get(event.event);
443
481
  if (handlers) {
444
482
  for (const handler of handlers) {
@@ -908,8 +946,8 @@ var AudioRecorder = class {
908
946
  let gap = trackPos - this._mixWritten;
909
947
  gap = gap - gap % 2;
910
948
  if (gap > 0) {
911
- const silence = Buffer.alloc(gap);
912
- fs.writeSync(this._fdMix, silence, 0, silence.length, 44 + this._mixWritten);
949
+ const silence2 = Buffer.alloc(gap);
950
+ fs.writeSync(this._fdMix, silence2, 0, silence2.length, 44 + this._mixWritten);
913
951
  this._mixWritten += gap;
914
952
  }
915
953
  }
@@ -1020,6 +1058,8 @@ var CallSession = class {
1020
1058
  /** @internal */
1021
1059
  _sendDtmfFn = null;
1022
1060
  /** @internal */
1061
+ _transferFn = null;
1062
+ /** @internal */
1023
1063
  _isTransportConnected = null;
1024
1064
  _dtmfCollectorActive = false;
1025
1065
  _dtmfResolvers = [];
@@ -1171,6 +1211,22 @@ var CallSession = class {
1171
1211
  }
1172
1212
  }
1173
1213
  }
1214
+ /** Transfer the call to another destination. */
1215
+ async transfer(to, options) {
1216
+ if (!this._transferFn) {
1217
+ throw new Error("transfer not available");
1218
+ }
1219
+ return this._transferFn({
1220
+ to,
1221
+ mode: options?.mode ?? "blind",
1222
+ afterTransfer: options?.afterTransfer ?? "terminate",
1223
+ holdMedia: options?.holdMedia ?? "ringback",
1224
+ whisper: options?.whisper ?? null,
1225
+ context: options?.context ?? null,
1226
+ callerId: options?.callerId ?? null,
1227
+ timeout: options?.timeout ?? 30
1228
+ });
1229
+ }
1174
1230
  /** Register an event handler. */
1175
1231
  on(event, handler) {
1176
1232
  let list = this._handlers.get(event);
@@ -1215,6 +1271,7 @@ var BuiltinTool = /* @__PURE__ */ ((BuiltinTool2) => {
1215
1271
  BuiltinTool2["HANG_UP"] = "hang_up";
1216
1272
  BuiltinTool2["COLLECT_DTMF"] = "collect_dtmf";
1217
1273
  BuiltinTool2["SEND_DTMF"] = "send_dtmf";
1274
+ BuiltinTool2["TRANSFER_CALL"] = "transfer_call";
1218
1275
  BuiltinTool2["ALL"] = "all";
1219
1276
  BuiltinTool2["NONE"] = "none";
1220
1277
  return BuiltinTool2;
@@ -1222,7 +1279,8 @@ var BuiltinTool = /* @__PURE__ */ ((BuiltinTool2) => {
1222
1279
  var INDIVIDUAL_TOOLS = /* @__PURE__ */ new Set([
1223
1280
  "hang_up" /* HANG_UP */,
1224
1281
  "collect_dtmf" /* COLLECT_DTMF */,
1225
- "send_dtmf" /* SEND_DTMF */
1282
+ "send_dtmf" /* SEND_DTMF */,
1283
+ "transfer_call" /* TRANSFER_CALL */
1226
1284
  ]);
1227
1285
  function resolveBuiltinTools(value) {
1228
1286
  if (typeof value === "string") {
@@ -1236,6 +1294,187 @@ function resolveBuiltinTools(value) {
1236
1294
  }
1237
1295
  return new Set(value.filter((t) => INDIVIDUAL_TOOLS.has(t)));
1238
1296
  }
1297
+ var CHUNK_SIZE = 160;
1298
+ var SAMPLE_RATE2 = 8e3;
1299
+ var BELL_PARTIALS = [
1300
+ { freqRatio: 1, amplitude: 1, decayRate: 1.2 },
1301
+ { freqRatio: 2.76, amplitude: 0.5, decayRate: 2.5 },
1302
+ { freqRatio: 5.4, amplitude: 0.25, decayRate: 4 }
1303
+ ];
1304
+ function bellNote(freq, durationMs, volume) {
1305
+ const n = SAMPLE_RATE2 * durationMs / 1e3 | 0;
1306
+ const attackSamples = 3e-3 * SAMPLE_RATE2 | 0;
1307
+ const samples = new Int16Array(n);
1308
+ for (let i = 0; i < n; i++) {
1309
+ const t = i / SAMPLE_RATE2;
1310
+ let val = 0;
1311
+ for (const p of BELL_PARTIALS) {
1312
+ const f = freq * p.freqRatio;
1313
+ if (f >= SAMPLE_RATE2 / 2) continue;
1314
+ const env = p.amplitude * Math.exp(-p.decayRate * t * (1e3 / durationMs));
1315
+ val += env * Math.sin(2 * Math.PI * f * t);
1316
+ }
1317
+ if (i < attackSamples) {
1318
+ val *= i / attackSamples;
1319
+ }
1320
+ samples[i] = Math.max(-32768, Math.min(32767, volume * 32767 * val | 0));
1321
+ }
1322
+ return samples;
1323
+ }
1324
+ function silence(durationMs) {
1325
+ return new Int16Array(SAMPLE_RATE2 * durationMs / 1e3 | 0);
1326
+ }
1327
+ function int16ArrayToBuffer(samples) {
1328
+ const buf = Buffer.alloc(samples.length * 2);
1329
+ for (let i = 0; i < samples.length; i++) {
1330
+ buf.writeInt16LE(samples[i], i * 2);
1331
+ }
1332
+ return buf;
1333
+ }
1334
+ function concatInt16Arrays(...arrays) {
1335
+ let total = 0;
1336
+ for (const a of arrays) total += a.length;
1337
+ const result = new Int16Array(total);
1338
+ let offset = 0;
1339
+ for (const a of arrays) {
1340
+ result.set(a, offset);
1341
+ offset += a.length;
1342
+ }
1343
+ return result;
1344
+ }
1345
+ var PENTATONIC_C5 = {
1346
+ C5: 523.25,
1347
+ D5: 587.33,
1348
+ E5: 659.25,
1349
+ G5: 783.99,
1350
+ A5: 880,
1351
+ C6: 1046.5
1352
+ };
1353
+ function generateComfortTone(volume = 0.12) {
1354
+ const p = PENTATONIC_C5;
1355
+ const melody = [
1356
+ [p.E5, 450],
1357
+ [p.G5, 450],
1358
+ [p.A5, 450],
1359
+ [p.C6, 600],
1360
+ [0, 800],
1361
+ [p.A5, 400],
1362
+ [p.G5, 400],
1363
+ [p.E5, 400],
1364
+ [p.D5, 600],
1365
+ [0, 2500],
1366
+ [p.C5, 500],
1367
+ [p.E5, 500],
1368
+ [p.C6, 700],
1369
+ [0, 2500]
1370
+ ];
1371
+ const parts = [];
1372
+ for (const [freq, durMs] of melody) {
1373
+ if (freq === 0) {
1374
+ parts.push(silence(durMs));
1375
+ } else {
1376
+ parts.push(bellNote(freq, durMs, volume));
1377
+ parts.push(silence(150));
1378
+ }
1379
+ }
1380
+ const pcm = int16ArrayToBuffer(concatInt16Arrays(...parts));
1381
+ const ulaw = pcm16ToUlaw(pcm);
1382
+ const chunks = [];
1383
+ for (let i = 0; i < ulaw.length; i += CHUNK_SIZE) {
1384
+ chunks.push(ulaw.subarray(i, i + CHUNK_SIZE));
1385
+ }
1386
+ return chunks;
1387
+ }
1388
+ function loadHoldAudio(source) {
1389
+ if (source === true) {
1390
+ return generateComfortTone();
1391
+ }
1392
+ if (Buffer.isBuffer(source)) {
1393
+ const chunks = [];
1394
+ for (let i = 0; i < source.length; i += CHUNK_SIZE) {
1395
+ chunks.push(source.subarray(i, i + CHUNK_SIZE));
1396
+ }
1397
+ return chunks;
1398
+ }
1399
+ if (typeof source === "string") {
1400
+ const data = fs.readFileSync(source);
1401
+ const riff = data.toString("ascii", 0, 4);
1402
+ if (riff !== "RIFF") {
1403
+ throw new Error(`\uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 \uD30C\uC77C \uD615\uC2DD\uC785\uB2C8\uB2E4: ${source}`);
1404
+ }
1405
+ let offset = 12;
1406
+ let channels = 1;
1407
+ let sampleRate = 8e3;
1408
+ let bitsPerSample = 16;
1409
+ let pcmData = null;
1410
+ while (offset < data.length - 8) {
1411
+ const chunkId = data.toString("ascii", offset, offset + 4);
1412
+ const chunkSize = data.readUInt32LE(offset + 4);
1413
+ if (chunkId === "fmt ") {
1414
+ channels = data.readUInt16LE(offset + 10);
1415
+ sampleRate = data.readUInt32LE(offset + 12);
1416
+ bitsPerSample = data.readUInt16LE(offset + 22);
1417
+ } else if (chunkId === "data") {
1418
+ pcmData = data.subarray(offset + 8, offset + 8 + chunkSize);
1419
+ }
1420
+ offset += 8 + chunkSize;
1421
+ if (chunkSize % 2 !== 0) offset++;
1422
+ }
1423
+ if (!pcmData) {
1424
+ throw new Error(`WAV data chunk\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${source}`);
1425
+ }
1426
+ if (bitsPerSample !== 16) {
1427
+ throw new Error(`16-bit PCM wav\uB9CC \uC9C0\uC6D0\uD569\uB2C8\uB2E4 (\uD604\uC7AC: ${bitsPerSample}-bit)`);
1428
+ }
1429
+ if (channels === 2) {
1430
+ const monoLen = pcmData.length / 2;
1431
+ const mono = Buffer.alloc(monoLen);
1432
+ for (let i = 0; i < monoLen / 2; i++) {
1433
+ mono.writeInt16LE(pcmData.readInt16LE(i * 4), i * 2);
1434
+ }
1435
+ pcmData = mono;
1436
+ }
1437
+ if (sampleRate !== SAMPLE_RATE2) {
1438
+ pcmData = resamplePcm16(pcmData, sampleRate, SAMPLE_RATE2);
1439
+ }
1440
+ const ulaw = pcm16ToUlaw(pcmData);
1441
+ const chunks = [];
1442
+ for (let i = 0; i < ulaw.length; i += CHUNK_SIZE) {
1443
+ chunks.push(ulaw.subarray(i, i + CHUNK_SIZE));
1444
+ }
1445
+ return chunks;
1446
+ }
1447
+ throw new TypeError(`\uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 holdAudio \uD0C0\uC785: ${typeof source}`);
1448
+ }
1449
+ var HoldAudioPlayer = class {
1450
+ _call;
1451
+ _chunks;
1452
+ _timer = null;
1453
+ _index = 0;
1454
+ constructor(call, chunks) {
1455
+ this._call = call;
1456
+ this._chunks = chunks;
1457
+ }
1458
+ start() {
1459
+ if (this._timer !== null) return;
1460
+ this._index = 0;
1461
+ this._timer = setInterval(() => {
1462
+ if (this._index >= this._chunks.length) {
1463
+ this._index = 0;
1464
+ }
1465
+ const chunk = this._chunks[this._index++];
1466
+ if (chunk) {
1467
+ this._call.sendAudio(chunk);
1468
+ }
1469
+ }, 20);
1470
+ }
1471
+ stop() {
1472
+ if (this._timer === null) return;
1473
+ clearInterval(this._timer);
1474
+ this._timer = null;
1475
+ this._call.clearAudio();
1476
+ }
1477
+ };
1239
1478
 
1240
1479
  // src/agent/tool.ts
1241
1480
  function functionTool(fn) {
@@ -1454,6 +1693,7 @@ var ClawOpsAgent = class {
1454
1693
  _log;
1455
1694
  _pipelineLog;
1456
1695
  _isPipelineSession = false;
1696
+ _holdAudioChunks = null;
1457
1697
  constructor(options) {
1458
1698
  this._apiKey = options.apiKey ?? process.env["CLAWOPS_API_KEY"] ?? "";
1459
1699
  this._accountId = options.accountId ?? process.env["CLAWOPS_ACCOUNT_ID"] ?? "";
@@ -1471,6 +1711,9 @@ var ClawOpsAgent = class {
1471
1711
  this._log = createAgentLogger(options.logger);
1472
1712
  this._pipelineLog = createPipelineLogger(this._log);
1473
1713
  this._isPipelineSession = "_stt" in this._session && "_llm" in this._session;
1714
+ if (options.toolConfig?.holdAudio) {
1715
+ this._holdAudioChunks = loadHoldAudio(options.toolConfig.holdAudio);
1716
+ }
1474
1717
  }
1475
1718
  /**
1476
1719
  * Register a function tool.
@@ -1775,6 +2018,7 @@ var ClawOpsAgent = class {
1775
2018
  },
1776
2019
  () => mediaWs.isConnected
1777
2020
  );
2021
+ session._transferFn = (params) => this._controlWs.requestTransfer(session.callId, params);
1778
2022
  const sessionHandler = this._session;
1779
2023
  if ("setToolRegistry" in sessionHandler && typeof sessionHandler.setToolRegistry === "function") {
1780
2024
  sessionHandler.setToolRegistry(sessionTools);
@@ -1788,6 +2032,9 @@ var ClawOpsAgent = class {
1788
2032
  if ("setLogger" in sessionHandler && typeof sessionHandler.setLogger === "function") {
1789
2033
  sessionHandler.setLogger(this._isPipelineSession ? this._pipelineLog : this._log);
1790
2034
  }
2035
+ if (this._holdAudioChunks && "setHoldAudio" in sessionHandler && typeof sessionHandler.setHoldAudio === "function") {
2036
+ sessionHandler.setHoldAudio(this._holdAudioChunks);
2037
+ }
1791
2038
  this._callSessions.set(session.callId, sessionHandler);
1792
2039
  mediaWs.onAudio((ulawAudio, _timestamp) => {
1793
2040
  if (sessionHandler) {
@@ -1889,10 +2136,27 @@ var SEND_DTMF = {
1889
2136
  required: ["digits"]
1890
2137
  }
1891
2138
  };
2139
+ var TRANSFER_CALL = {
2140
+ name: "transfer_call",
2141
+ description: "Transfer the current call to another phone number. Use for blind transfer (direct handoff) or warm transfer (with whisper message to the target).",
2142
+ parameters: {
2143
+ type: "object",
2144
+ properties: {
2145
+ to: { type: "string", description: "Phone number to transfer to" },
2146
+ mode: { type: "string", enum: ["blind", "warm"], description: "blind: direct transfer (default), warm: play whisper to target first" },
2147
+ after_transfer: { type: "string", enum: ["terminate", "return"], description: "terminate: end AI session (default), return: AI resumes after transfer ends" },
2148
+ whisper: { type: "string", description: "Message to speak to transfer target before connecting customer (warm mode only)" },
2149
+ caller_id: { type: "string", description: "Override caller ID for the transfer leg" },
2150
+ timeout: { type: "integer", description: "Seconds to wait for transfer target to answer (default 30)" }
2151
+ },
2152
+ required: ["to"]
2153
+ }
2154
+ };
1892
2155
  var TOOL_MAP = /* @__PURE__ */ new Map([
1893
2156
  ["hang_up" /* HANG_UP */, HANG_UP],
1894
2157
  ["collect_dtmf" /* COLLECT_DTMF */, COLLECT_DTMF],
1895
- ["send_dtmf" /* SEND_DTMF */, SEND_DTMF]
2158
+ ["send_dtmf" /* SEND_DTMF */, SEND_DTMF],
2159
+ ["transfer_call" /* TRANSFER_CALL */, TRANSFER_CALL]
1896
2160
  ]);
1897
2161
  var BUILTIN_TOOL_NAMES = new Set(
1898
2162
  Array.from(TOOL_MAP.values()).map((s) => s.name)
@@ -1965,6 +2229,21 @@ async function executeBuiltinTool(funcName, args, call) {
1965
2229
  return `Error: ${e}`;
1966
2230
  }
1967
2231
  }
2232
+ if (funcName === "transfer_call") {
2233
+ try {
2234
+ call.transfer(args["to"], {
2235
+ mode: args["mode"] ?? void 0,
2236
+ afterTransfer: args["after_transfer"] ?? void 0,
2237
+ whisper: args["whisper"] ?? void 0,
2238
+ callerId: args["caller_id"] ?? void 0,
2239
+ timeout: args["timeout"] ?? void 0
2240
+ }).catch(() => {
2241
+ });
2242
+ return JSON.stringify({ status: "transfer_initiated" });
2243
+ } catch (e) {
2244
+ return `Error: ${e}`;
2245
+ }
2246
+ }
1968
2247
  return null;
1969
2248
  }
1970
2249
 
@@ -1988,6 +2267,7 @@ var PipelineSession = class {
1988
2267
  _running = false;
1989
2268
  _speaking = false;
1990
2269
  _builtinTools = null;
2270
+ _holdAudioChunks = null;
1991
2271
  _log = NOOP_LOGGER;
1992
2272
  constructor(options) {
1993
2273
  this._stt = options.stt;
@@ -2012,6 +2292,10 @@ var PipelineSession = class {
2012
2292
  setBuiltinTools(tools) {
2013
2293
  this._builtinTools = tools;
2014
2294
  }
2295
+ /** Tool 실행 중 재생할 hold audio 청크를 설정한다. */
2296
+ setHoldAudio(chunks) {
2297
+ this._holdAudioChunks = chunks;
2298
+ }
2015
2299
  getTelemetry() {
2016
2300
  const llm = this._llm;
2017
2301
  const stt = this._stt;
@@ -2177,7 +2461,14 @@ var PipelineSession = class {
2177
2461
  }
2178
2462
  if (!this._tools) return;
2179
2463
  this._callSession?.recordToolCall();
2180
- const result = await this._tools.call(name, args);
2464
+ const player = this._holdAudioChunks && this._callSession ? new HoldAudioPlayer(this._callSession, this._holdAudioChunks) : null;
2465
+ player?.start();
2466
+ let result;
2467
+ try {
2468
+ result = await this._tools.call(name, args);
2469
+ } finally {
2470
+ player?.stop();
2471
+ }
2181
2472
  this._conversation.push({
2182
2473
  role: "assistant",
2183
2474
  content: ""
@@ -2289,6 +2580,8 @@ var OpenAIRealtime = class {
2289
2580
  // Response state tracking — prevent sending response.create while one is active
2290
2581
  _responseInProgress = false;
2291
2582
  _onResponseDone = null;
2583
+ // Hold audio — tool 실행 중 대기음
2584
+ _holdAudioChunks = null;
2292
2585
  constructor(options = {}) {
2293
2586
  this._apiKey = options.apiKey ?? process.env["OPENAI_API_KEY"] ?? "";
2294
2587
  this._systemPrompt = options.systemPrompt ?? "";
@@ -2306,6 +2599,10 @@ var OpenAIRealtime = class {
2306
2599
  setRecorder(recorder) {
2307
2600
  this._recorder = recorder;
2308
2601
  }
2602
+ /** Tool 실행 중 재생할 hold audio 청크를 설정한다. */
2603
+ setHoldAudio(chunks) {
2604
+ this._holdAudioChunks = chunks;
2605
+ }
2309
2606
  async start(callSession, tools) {
2310
2607
  this._call = callSession;
2311
2608
  if (tools) this._tools = tools;
@@ -2561,6 +2858,8 @@ var OpenAIRealtime = class {
2561
2858
  return;
2562
2859
  }
2563
2860
  let result;
2861
+ const player = this._holdAudioChunks && this._call ? new HoldAudioPlayer(this._call, this._holdAudioChunks) : null;
2862
+ player?.start();
2564
2863
  try {
2565
2864
  const args = JSON.parse(item["arguments"] ?? "{}");
2566
2865
  this._call?.recordToolCall();
@@ -2571,6 +2870,8 @@ var OpenAIRealtime = class {
2571
2870
  this._call?.recordToolError(err);
2572
2871
  }
2573
2872
  result = `Error: ${err}`;
2873
+ } finally {
2874
+ player?.stop();
2574
2875
  }
2575
2876
  if (controller.signal.aborted) {
2576
2877
  this._log.info("Tool call cancelled (user interrupted): %s", funcName);
@@ -2678,7 +2979,7 @@ function sanitizeSchemaForGemini(schema, defs, depth = 0) {
2678
2979
  if (result["type"] === "object" && !result["properties"]) result["properties"] = {};
2679
2980
  return result;
2680
2981
  }
2681
- var GeminiRealtime = class {
2982
+ var GeminiRealtime = class _GeminiRealtime {
2682
2983
  _apiKey;
2683
2984
  _systemPrompt;
2684
2985
  _model;
@@ -2694,12 +2995,15 @@ var GeminiRealtime = class {
2694
2995
  _sentAudioChunks = 0;
2695
2996
  _audioRemainder = Buffer.alloc(0);
2696
2997
  _builtinTools = null;
2697
- _toolCallInProgress = false;
2998
+ _pendingToolCall = null;
2999
+ _toolDrainTimer = null;
3000
+ _lastAudioTime = 0;
3001
+ _holdAudioChunks = null;
2698
3002
  _log = NOOP_LOGGER;
2699
3003
  constructor(options = {}) {
2700
3004
  this._apiKey = options.apiKey ?? process.env["GOOGLE_API_KEY"] ?? "";
2701
3005
  this._systemPrompt = options.systemPrompt ?? "";
2702
- this._model = options.model ?? "gemini-2.5-flash-native-audio-preview-12-2025";
3006
+ this._model = options.model ?? "gemini-3.1-flash-live-preview";
2703
3007
  this._voice = options.voice ?? "Kore";
2704
3008
  this._language = options.language ?? "ko";
2705
3009
  this._greeting = options.greeting ?? true;
@@ -2715,6 +3019,10 @@ var GeminiRealtime = class {
2715
3019
  setBuiltinTools(tools) {
2716
3020
  this._builtinTools = tools;
2717
3021
  }
3022
+ /** Tool 실행 중 재생할 hold audio 청크를 설정한다. */
3023
+ setHoldAudio(chunks) {
3024
+ this._holdAudioChunks = chunks;
3025
+ }
2718
3026
  setLogger(logger) {
2719
3027
  this._log = logger;
2720
3028
  }
@@ -2757,7 +3065,9 @@ var GeminiRealtime = class {
2757
3065
  outputAudioTranscription: {}
2758
3066
  };
2759
3067
  if (this._systemPrompt) {
2760
- config["systemInstruction"] = this._systemPrompt;
3068
+ config["systemInstruction"] = {
3069
+ parts: [{ text: this._systemPrompt }]
3070
+ };
2761
3071
  }
2762
3072
  const toolSchemas = this._buildToolSchemas();
2763
3073
  if (toolSchemas.length > 0) {
@@ -2772,25 +3082,20 @@ var GeminiRealtime = class {
2772
3082
  this._log.error({ err }, "Gemini SDK error");
2773
3083
  },
2774
3084
  onclose: (ev) => {
2775
- this._log.info({ code: ev?.code ?? "unknown" }, "Gemini connection closed");
3085
+ this._log.info(
3086
+ { code: ev?.code ?? "unknown" },
3087
+ "Gemini connection closed"
3088
+ );
2776
3089
  this._closed = true;
2777
3090
  }
2778
3091
  }
2779
3092
  });
2780
3093
  if (this._greeting) {
2781
- this._session.sendClientContent({
2782
- turns: [
2783
- {
2784
- role: "user",
2785
- parts: [{ text: "\uC778\uC0AC\uD574 \uC8FC\uC138\uC694." }]
2786
- }
2787
- ],
2788
- turnComplete: true
2789
- });
3094
+ this._session.sendRealtimeInput({ text: "\uC778\uC0AC\uD574 \uC8FC\uC138\uC694." });
2790
3095
  }
2791
3096
  }
2792
3097
  feedAudio(audio) {
2793
- if (this._session && !this._closed && !this._toolCallInProgress) {
3098
+ if (this._session && !this._closed) {
2794
3099
  const pcm8k = ulawToPcm16(audio);
2795
3100
  if (this._recorder) {
2796
3101
  this._recorder.writeInbound(pcm8k);
@@ -2806,14 +3111,16 @@ var GeminiRealtime = class {
2806
3111
  }
2807
3112
  async feedDtmf(digits) {
2808
3113
  if (this._session) {
2809
- this._session.sendClientContent({
2810
- turns: [{ role: "user", parts: [{ text: `[DTMF \uC785\uB825: ${digits}]` }] }],
2811
- turnComplete: true
2812
- });
3114
+ this._session.sendRealtimeInput({ text: `[DTMF \uC785\uB825: ${digits}]` });
2813
3115
  }
2814
3116
  }
2815
3117
  async stop() {
2816
3118
  this._closed = true;
3119
+ if (this._toolDrainTimer) {
3120
+ clearTimeout(this._toolDrainTimer);
3121
+ this._toolDrainTimer = null;
3122
+ }
3123
+ this._pendingToolCall = null;
2817
3124
  if (this._session) {
2818
3125
  try {
2819
3126
  this._session.close();
@@ -2845,6 +3152,7 @@ var GeminiRealtime = class {
2845
3152
  const mimeType = inlineData.mimeType ?? "";
2846
3153
  if (mimeType.includes("audio")) {
2847
3154
  this._handleAudioData(inlineData.data);
3155
+ if (this._pendingToolCall) this._lastAudioTime = Date.now();
2848
3156
  }
2849
3157
  }
2850
3158
  }
@@ -2873,11 +3181,17 @@ var GeminiRealtime = class {
2873
3181
  }
2874
3182
  }
2875
3183
  if (msg.toolCall) {
2876
- this._handleToolCall(msg.toolCall);
3184
+ this._pendingToolCall = msg.toolCall;
3185
+ this._scheduleToolExecution();
2877
3186
  }
2878
3187
  const toolCancellation = msg["toolCallCancellation"];
2879
3188
  if (toolCancellation) {
2880
3189
  this._log.info({ ids: toolCancellation.ids }, "Tool call cancelled");
3190
+ this._pendingToolCall = null;
3191
+ if (this._toolDrainTimer) {
3192
+ clearTimeout(this._toolDrainTimer);
3193
+ this._toolDrainTimer = null;
3194
+ }
2881
3195
  }
2882
3196
  }
2883
3197
  _handleAudioData(b64Data) {
@@ -2908,54 +3222,81 @@ var GeminiRealtime = class {
2908
3222
  this._audioRemainder = Buffer.alloc(0);
2909
3223
  }
2910
3224
  }
3225
+ // Gemini는 tool_call 후에도 오디오를 계속 보내므로, 이 시간 내 응답이 없으면 drain 완료로 간주
3226
+ static TOOL_DRAIN_TIMEOUT = 300;
3227
+ _scheduleToolExecution() {
3228
+ if (this._toolDrainTimer) return;
3229
+ this._lastAudioTime = Date.now();
3230
+ this._toolDrainTimer = setTimeout(() => {
3231
+ this._toolDrainTimer = null;
3232
+ if (Date.now() - this._lastAudioTime < _GeminiRealtime.TOOL_DRAIN_TIMEOUT) {
3233
+ this._scheduleToolExecution();
3234
+ return;
3235
+ }
3236
+ if (this._pendingToolCall) {
3237
+ const tc = this._pendingToolCall;
3238
+ this._pendingToolCall = null;
3239
+ this._handleToolCall(tc);
3240
+ }
3241
+ }, _GeminiRealtime.TOOL_DRAIN_TIMEOUT);
3242
+ }
2911
3243
  async _handleToolCall(toolCall) {
2912
3244
  const functionCalls = toolCall.functionCalls;
2913
3245
  if (!functionCalls) return;
2914
- this._toolCallInProgress = true;
2915
3246
  const responses = [];
2916
- for (const fc of functionCalls) {
2917
- const name = fc.name ?? "";
2918
- const fcId = fc.id ?? "";
2919
- const args = fc.args ?? {};
2920
- this._log.info({ tool: name, args }, "Tool call: %s", name);
2921
- if (BUILTIN_TOOL_NAMES.has(name) && this._call) {
2922
- const result = await executeBuiltinTool(name, args, this._call);
2923
- if (result !== null) {
2924
- if (name === "hang_up") {
2925
- this._log.info("hang_up: ending call");
2926
- return;
3247
+ const player = this._holdAudioChunks && this._call ? new HoldAudioPlayer(this._call, this._holdAudioChunks) : null;
3248
+ player?.start();
3249
+ try {
3250
+ for (const fc of functionCalls) {
3251
+ const name = fc.name ?? "";
3252
+ const fcId = fc.id ?? "";
3253
+ const args = fc.args ?? {};
3254
+ this._log.info({ tool: name, args }, "Tool call: %s", name);
3255
+ if (BUILTIN_TOOL_NAMES.has(name) && this._call) {
3256
+ const result = await executeBuiltinTool(
3257
+ name,
3258
+ args,
3259
+ this._call
3260
+ );
3261
+ if (result !== null) {
3262
+ if (name === "hang_up") {
3263
+ this._log.info("hang_up: ending call");
3264
+ return;
3265
+ }
3266
+ this._log.info("Builtin tool result: %s -> %s", name, result);
3267
+ responses.push({ id: fcId, name, response: { result } });
3268
+ continue;
2927
3269
  }
2928
- this._log.info("Builtin tool result: %s -> %s", name, result);
2929
- responses.push({ id: fcId, name, response: { result } });
3270
+ }
3271
+ if (!this._tools || !this._tools.has(name)) {
3272
+ this._log.error("Unknown tool: %s", name);
3273
+ responses.push({ id: fcId, name, response: { error: `Unknown tool: ${name}` } });
2930
3274
  continue;
2931
3275
  }
2932
- }
2933
- if (!this._tools || !this._tools.has(name)) {
2934
- this._log.error("Unknown tool: %s", name);
2935
- responses.push({ id: fcId, name, response: { error: `Unknown tool: ${name}` } });
2936
- continue;
2937
- }
2938
- try {
2939
- this._call?.recordToolCall();
2940
- const result = await this._tools.call(name, args);
2941
- const resultStr = typeof result === "string" ? result : JSON.stringify(result);
2942
- this._log.info("Tool result: %s -> %s", name, resultStr.substring(0, 200));
2943
- responses.push({
2944
- id: fcId,
2945
- name,
2946
- response: { result: resultStr }
2947
- });
2948
- } catch (err) {
2949
- this._log.error({ err }, "Tool call failed: %s", name);
2950
- if (err instanceof Error) {
2951
- this._call?.recordToolError(err);
3276
+ try {
3277
+ this._call?.recordToolCall();
3278
+ const result = await this._tools.call(name, args);
3279
+ const resultStr = typeof result === "string" ? result : JSON.stringify(result);
3280
+ this._log.info("Tool result: %s -> %s", name, resultStr.substring(0, 200));
3281
+ responses.push({
3282
+ id: fcId,
3283
+ name,
3284
+ response: { result: resultStr }
3285
+ });
3286
+ } catch (err) {
3287
+ this._log.error({ err }, "Tool call failed: %s", name);
3288
+ if (err instanceof Error) {
3289
+ this._call?.recordToolError(err);
3290
+ }
3291
+ responses.push({
3292
+ id: fcId,
3293
+ name,
3294
+ response: { error: String(err) }
3295
+ });
2952
3296
  }
2953
- responses.push({
2954
- id: fcId,
2955
- name,
2956
- response: { error: String(err) }
2957
- });
2958
3297
  }
3298
+ } finally {
3299
+ player?.stop();
2959
3300
  }
2960
3301
  if (responses.length > 0 && this._session) {
2961
3302
  this._log.debug("Sending %d tool response(s)", responses.length);
@@ -2963,7 +3304,6 @@ var GeminiRealtime = class {
2963
3304
  functionResponses: responses
2964
3305
  });
2965
3306
  }
2966
- this._toolCallInProgress = false;
2967
3307
  }
2968
3308
  };
2969
3309