@teamlearners/clawops 0.8.1 → 0.12.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,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunkMOH4FRJZ_cjs = require('../chunk-MOH4FRJZ.cjs');
3
+ var chunkX6VG5NNG_cjs = require('../chunk-X6VG5NNG.cjs');
4
4
  var pino = require('pino');
5
5
  var fs = require('fs');
6
6
  var path = require('path');
@@ -384,6 +384,7 @@ var ControlWebSocket = class {
384
384
  _url;
385
385
  _ws = null;
386
386
  _handlers = /* @__PURE__ */ new Map();
387
+ _transferResolvers = /* @__PURE__ */ new Map();
387
388
  _reconnectDelay = INITIAL_RECONNECT_DELAY;
388
389
  _closed = false;
389
390
  _connectedResolve = null;
@@ -411,6 +412,31 @@ var ControlWebSocket = class {
411
412
  async waitConnected() {
412
413
  return this._connectedPromise;
413
414
  }
415
+ /** Request a call transfer and wait for the result. */
416
+ async requestTransfer(callId, params) {
417
+ return new Promise((resolve, reject) => {
418
+ const timeout = (params.timeout || 30) + 10;
419
+ const timer = setTimeout(() => {
420
+ this._transferResolvers.delete(callId);
421
+ reject(new Error("transfer timeout"));
422
+ }, timeout * 1e3);
423
+ this._transferResolvers.set(callId, {
424
+ resolve: (value) => {
425
+ clearTimeout(timer);
426
+ resolve(value);
427
+ },
428
+ reject: (reason) => {
429
+ clearTimeout(timer);
430
+ reject(reason);
431
+ }
432
+ });
433
+ this.send({
434
+ event: "call.transfer",
435
+ callId,
436
+ transfer: params
437
+ });
438
+ });
439
+ }
414
440
  /** Send a JSON message over the control WebSocket. */
415
441
  send(message) {
416
442
  if (this._ws && this._ws.readyState === 1) {
@@ -421,6 +447,10 @@ var ControlWebSocket = class {
421
447
  close() {
422
448
  this._closed = true;
423
449
  this._clearPingTimer();
450
+ for (const [, resolver] of this._transferResolvers) {
451
+ resolver.reject(new Error("connection closed"));
452
+ }
453
+ this._transferResolvers.clear();
424
454
  if (this._ws) {
425
455
  this._ws.close();
426
456
  this._ws = null;
@@ -466,6 +496,14 @@ var ControlWebSocket = class {
466
496
  });
467
497
  }
468
498
  _dispatchEvent(event) {
499
+ if (["call.transfer.completed", "call.transfer.failed"].includes(event.event)) {
500
+ const callId = event.callId;
501
+ const resolver = this._transferResolvers.get(callId);
502
+ if (resolver) {
503
+ this._transferResolvers.delete(callId);
504
+ resolver.resolve(event.transfer || {});
505
+ }
506
+ }
469
507
  const handlers = this._handlers.get(event.event);
470
508
  if (handlers) {
471
509
  for (const handler of handlers) {
@@ -935,8 +973,8 @@ var AudioRecorder = class {
935
973
  let gap = trackPos - this._mixWritten;
936
974
  gap = gap - gap % 2;
937
975
  if (gap > 0) {
938
- const silence = Buffer.alloc(gap);
939
- fs__namespace.writeSync(this._fdMix, silence, 0, silence.length, 44 + this._mixWritten);
976
+ const silence2 = Buffer.alloc(gap);
977
+ fs__namespace.writeSync(this._fdMix, silence2, 0, silence2.length, 44 + this._mixWritten);
940
978
  this._mixWritten += gap;
941
979
  }
942
980
  }
@@ -1005,7 +1043,7 @@ var MAX_ERROR_MESSAGE_LENGTH = 200;
1005
1043
  function getSdkInfo() {
1006
1044
  return {
1007
1045
  name: "clawops-node",
1008
- version: chunkMOH4FRJZ_cjs.VERSION,
1046
+ version: chunkX6VG5NNG_cjs.VERSION,
1009
1047
  runtime: `node/${process.versions.node}`,
1010
1048
  os: `${process.platform}/${os__default.default.arch()}`
1011
1049
  };
@@ -1047,6 +1085,8 @@ var CallSession = class {
1047
1085
  /** @internal */
1048
1086
  _sendDtmfFn = null;
1049
1087
  /** @internal */
1088
+ _transferFn = null;
1089
+ /** @internal */
1050
1090
  _isTransportConnected = null;
1051
1091
  _dtmfCollectorActive = false;
1052
1092
  _dtmfResolvers = [];
@@ -1198,6 +1238,22 @@ var CallSession = class {
1198
1238
  }
1199
1239
  }
1200
1240
  }
1241
+ /** Transfer the call to another destination. */
1242
+ async transfer(to, options) {
1243
+ if (!this._transferFn) {
1244
+ throw new Error("transfer not available");
1245
+ }
1246
+ return this._transferFn({
1247
+ to,
1248
+ mode: options?.mode ?? "blind",
1249
+ afterTransfer: options?.afterTransfer ?? "terminate",
1250
+ holdMedia: options?.holdMedia ?? "ringback",
1251
+ whisper: options?.whisper ?? null,
1252
+ context: options?.context ?? null,
1253
+ callerId: options?.callerId ?? null,
1254
+ timeout: options?.timeout ?? 30
1255
+ });
1256
+ }
1201
1257
  /** Register an event handler. */
1202
1258
  on(event, handler) {
1203
1259
  let list = this._handlers.get(event);
@@ -1242,6 +1298,7 @@ var BuiltinTool = /* @__PURE__ */ ((BuiltinTool2) => {
1242
1298
  BuiltinTool2["HANG_UP"] = "hang_up";
1243
1299
  BuiltinTool2["COLLECT_DTMF"] = "collect_dtmf";
1244
1300
  BuiltinTool2["SEND_DTMF"] = "send_dtmf";
1301
+ BuiltinTool2["TRANSFER_CALL"] = "transfer_call";
1245
1302
  BuiltinTool2["ALL"] = "all";
1246
1303
  BuiltinTool2["NONE"] = "none";
1247
1304
  return BuiltinTool2;
@@ -1249,7 +1306,8 @@ var BuiltinTool = /* @__PURE__ */ ((BuiltinTool2) => {
1249
1306
  var INDIVIDUAL_TOOLS = /* @__PURE__ */ new Set([
1250
1307
  "hang_up" /* HANG_UP */,
1251
1308
  "collect_dtmf" /* COLLECT_DTMF */,
1252
- "send_dtmf" /* SEND_DTMF */
1309
+ "send_dtmf" /* SEND_DTMF */,
1310
+ "transfer_call" /* TRANSFER_CALL */
1253
1311
  ]);
1254
1312
  function resolveBuiltinTools(value) {
1255
1313
  if (typeof value === "string") {
@@ -1263,6 +1321,187 @@ function resolveBuiltinTools(value) {
1263
1321
  }
1264
1322
  return new Set(value.filter((t) => INDIVIDUAL_TOOLS.has(t)));
1265
1323
  }
1324
+ var CHUNK_SIZE = 160;
1325
+ var SAMPLE_RATE2 = 8e3;
1326
+ var BELL_PARTIALS = [
1327
+ { freqRatio: 1, amplitude: 1, decayRate: 1.2 },
1328
+ { freqRatio: 2.76, amplitude: 0.5, decayRate: 2.5 },
1329
+ { freqRatio: 5.4, amplitude: 0.25, decayRate: 4 }
1330
+ ];
1331
+ function bellNote(freq, durationMs, volume) {
1332
+ const n = SAMPLE_RATE2 * durationMs / 1e3 | 0;
1333
+ const attackSamples = 3e-3 * SAMPLE_RATE2 | 0;
1334
+ const samples = new Int16Array(n);
1335
+ for (let i = 0; i < n; i++) {
1336
+ const t = i / SAMPLE_RATE2;
1337
+ let val = 0;
1338
+ for (const p of BELL_PARTIALS) {
1339
+ const f = freq * p.freqRatio;
1340
+ if (f >= SAMPLE_RATE2 / 2) continue;
1341
+ const env = p.amplitude * Math.exp(-p.decayRate * t * (1e3 / durationMs));
1342
+ val += env * Math.sin(2 * Math.PI * f * t);
1343
+ }
1344
+ if (i < attackSamples) {
1345
+ val *= i / attackSamples;
1346
+ }
1347
+ samples[i] = Math.max(-32768, Math.min(32767, volume * 32767 * val | 0));
1348
+ }
1349
+ return samples;
1350
+ }
1351
+ function silence(durationMs) {
1352
+ return new Int16Array(SAMPLE_RATE2 * durationMs / 1e3 | 0);
1353
+ }
1354
+ function int16ArrayToBuffer(samples) {
1355
+ const buf = Buffer.alloc(samples.length * 2);
1356
+ for (let i = 0; i < samples.length; i++) {
1357
+ buf.writeInt16LE(samples[i], i * 2);
1358
+ }
1359
+ return buf;
1360
+ }
1361
+ function concatInt16Arrays(...arrays) {
1362
+ let total = 0;
1363
+ for (const a of arrays) total += a.length;
1364
+ const result = new Int16Array(total);
1365
+ let offset = 0;
1366
+ for (const a of arrays) {
1367
+ result.set(a, offset);
1368
+ offset += a.length;
1369
+ }
1370
+ return result;
1371
+ }
1372
+ var PENTATONIC_C5 = {
1373
+ C5: 523.25,
1374
+ D5: 587.33,
1375
+ E5: 659.25,
1376
+ G5: 783.99,
1377
+ A5: 880,
1378
+ C6: 1046.5
1379
+ };
1380
+ function generateComfortTone(volume = 0.12) {
1381
+ const p = PENTATONIC_C5;
1382
+ const melody = [
1383
+ [p.E5, 450],
1384
+ [p.G5, 450],
1385
+ [p.A5, 450],
1386
+ [p.C6, 600],
1387
+ [0, 800],
1388
+ [p.A5, 400],
1389
+ [p.G5, 400],
1390
+ [p.E5, 400],
1391
+ [p.D5, 600],
1392
+ [0, 2500],
1393
+ [p.C5, 500],
1394
+ [p.E5, 500],
1395
+ [p.C6, 700],
1396
+ [0, 2500]
1397
+ ];
1398
+ const parts = [];
1399
+ for (const [freq, durMs] of melody) {
1400
+ if (freq === 0) {
1401
+ parts.push(silence(durMs));
1402
+ } else {
1403
+ parts.push(bellNote(freq, durMs, volume));
1404
+ parts.push(silence(150));
1405
+ }
1406
+ }
1407
+ const pcm = int16ArrayToBuffer(concatInt16Arrays(...parts));
1408
+ const ulaw = pcm16ToUlaw(pcm);
1409
+ const chunks = [];
1410
+ for (let i = 0; i < ulaw.length; i += CHUNK_SIZE) {
1411
+ chunks.push(ulaw.subarray(i, i + CHUNK_SIZE));
1412
+ }
1413
+ return chunks;
1414
+ }
1415
+ function loadHoldAudio(source) {
1416
+ if (source === true) {
1417
+ return generateComfortTone();
1418
+ }
1419
+ if (Buffer.isBuffer(source)) {
1420
+ const chunks = [];
1421
+ for (let i = 0; i < source.length; i += CHUNK_SIZE) {
1422
+ chunks.push(source.subarray(i, i + CHUNK_SIZE));
1423
+ }
1424
+ return chunks;
1425
+ }
1426
+ if (typeof source === "string") {
1427
+ const data = fs__namespace.readFileSync(source);
1428
+ const riff = data.toString("ascii", 0, 4);
1429
+ if (riff !== "RIFF") {
1430
+ throw new Error(`\uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 \uD30C\uC77C \uD615\uC2DD\uC785\uB2C8\uB2E4: ${source}`);
1431
+ }
1432
+ let offset = 12;
1433
+ let channels = 1;
1434
+ let sampleRate = 8e3;
1435
+ let bitsPerSample = 16;
1436
+ let pcmData = null;
1437
+ while (offset < data.length - 8) {
1438
+ const chunkId = data.toString("ascii", offset, offset + 4);
1439
+ const chunkSize = data.readUInt32LE(offset + 4);
1440
+ if (chunkId === "fmt ") {
1441
+ channels = data.readUInt16LE(offset + 10);
1442
+ sampleRate = data.readUInt32LE(offset + 12);
1443
+ bitsPerSample = data.readUInt16LE(offset + 22);
1444
+ } else if (chunkId === "data") {
1445
+ pcmData = data.subarray(offset + 8, offset + 8 + chunkSize);
1446
+ }
1447
+ offset += 8 + chunkSize;
1448
+ if (chunkSize % 2 !== 0) offset++;
1449
+ }
1450
+ if (!pcmData) {
1451
+ throw new Error(`WAV data chunk\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${source}`);
1452
+ }
1453
+ if (bitsPerSample !== 16) {
1454
+ throw new Error(`16-bit PCM wav\uB9CC \uC9C0\uC6D0\uD569\uB2C8\uB2E4 (\uD604\uC7AC: ${bitsPerSample}-bit)`);
1455
+ }
1456
+ if (channels === 2) {
1457
+ const monoLen = pcmData.length / 2;
1458
+ const mono = Buffer.alloc(monoLen);
1459
+ for (let i = 0; i < monoLen / 2; i++) {
1460
+ mono.writeInt16LE(pcmData.readInt16LE(i * 4), i * 2);
1461
+ }
1462
+ pcmData = mono;
1463
+ }
1464
+ if (sampleRate !== SAMPLE_RATE2) {
1465
+ pcmData = resamplePcm16(pcmData, sampleRate, SAMPLE_RATE2);
1466
+ }
1467
+ const ulaw = pcm16ToUlaw(pcmData);
1468
+ const chunks = [];
1469
+ for (let i = 0; i < ulaw.length; i += CHUNK_SIZE) {
1470
+ chunks.push(ulaw.subarray(i, i + CHUNK_SIZE));
1471
+ }
1472
+ return chunks;
1473
+ }
1474
+ throw new TypeError(`\uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 holdAudio \uD0C0\uC785: ${typeof source}`);
1475
+ }
1476
+ var HoldAudioPlayer = class {
1477
+ _call;
1478
+ _chunks;
1479
+ _timer = null;
1480
+ _index = 0;
1481
+ constructor(call, chunks) {
1482
+ this._call = call;
1483
+ this._chunks = chunks;
1484
+ }
1485
+ start() {
1486
+ if (this._timer !== null) return;
1487
+ this._index = 0;
1488
+ this._timer = setInterval(() => {
1489
+ if (this._index >= this._chunks.length) {
1490
+ this._index = 0;
1491
+ }
1492
+ const chunk = this._chunks[this._index++];
1493
+ if (chunk) {
1494
+ this._call.sendAudio(chunk);
1495
+ }
1496
+ }, 20);
1497
+ }
1498
+ stop() {
1499
+ if (this._timer === null) return;
1500
+ clearInterval(this._timer);
1501
+ this._timer = null;
1502
+ this._call.clearAudio();
1503
+ }
1504
+ };
1266
1505
 
1267
1506
  // src/agent/tool.ts
1268
1507
  function functionTool(fn) {
@@ -1481,10 +1720,11 @@ var ClawOpsAgent = class {
1481
1720
  _log;
1482
1721
  _pipelineLog;
1483
1722
  _isPipelineSession = false;
1723
+ _holdAudioChunks = null;
1484
1724
  constructor(options) {
1485
1725
  this._apiKey = options.apiKey ?? process.env["CLAWOPS_API_KEY"] ?? "";
1486
1726
  this._accountId = options.accountId ?? process.env["CLAWOPS_ACCOUNT_ID"] ?? "";
1487
- this._baseUrl = options.baseUrl ?? chunkMOH4FRJZ_cjs.DEFAULT_BASE_URL;
1727
+ this._baseUrl = options.baseUrl ?? chunkX6VG5NNG_cjs.DEFAULT_BASE_URL;
1488
1728
  this._fromNumber = options.from;
1489
1729
  this._session = options.session;
1490
1730
  this._recording = options.recording ?? false;
@@ -1498,6 +1738,9 @@ var ClawOpsAgent = class {
1498
1738
  this._log = createAgentLogger(options.logger);
1499
1739
  this._pipelineLog = createPipelineLogger(this._log);
1500
1740
  this._isPipelineSession = "_stt" in this._session && "_llm" in this._session;
1741
+ if (options.toolConfig?.holdAudio) {
1742
+ this._holdAudioChunks = loadHoldAudio(options.toolConfig.holdAudio);
1743
+ }
1501
1744
  }
1502
1745
  /**
1503
1746
  * Register a function tool.
@@ -1509,7 +1752,7 @@ var ClawOpsAgent = class {
1509
1752
  tool(nameOrTool, description, parameters, handler) {
1510
1753
  if (typeof nameOrTool === "string") {
1511
1754
  if (!description || !parameters || !handler) {
1512
- throw new chunkMOH4FRJZ_cjs.AgentError(
1755
+ throw new chunkX6VG5NNG_cjs.AgentError(
1513
1756
  "tool(name, description, parameters, handler) requires all arguments."
1514
1757
  );
1515
1758
  }
@@ -1545,10 +1788,10 @@ var ClawOpsAgent = class {
1545
1788
  async connect() {
1546
1789
  if (this._controlWs) return;
1547
1790
  if (!this._apiKey) {
1548
- throw new chunkMOH4FRJZ_cjs.AgentError("API key is required. Set CLAWOPS_API_KEY or pass apiKey option.");
1791
+ throw new chunkX6VG5NNG_cjs.AgentError("API key is required. Set CLAWOPS_API_KEY or pass apiKey option.");
1549
1792
  }
1550
1793
  if (!this._accountId) {
1551
- throw new chunkMOH4FRJZ_cjs.AgentError(
1794
+ throw new chunkX6VG5NNG_cjs.AgentError(
1552
1795
  "Account ID is required. Set CLAWOPS_ACCOUNT_ID or pass accountId option."
1553
1796
  );
1554
1797
  }
@@ -1572,7 +1815,7 @@ var ClawOpsAgent = class {
1572
1815
  } catch {
1573
1816
  }
1574
1817
  } catch (err) {
1575
- throw new chunkMOH4FRJZ_cjs.AgentConnectionError(
1818
+ throw new chunkX6VG5NNG_cjs.AgentConnectionError(
1576
1819
  `Failed to connect to ClawOps: ${err instanceof Error ? err.message : String(err)}`
1577
1820
  );
1578
1821
  }
@@ -1623,7 +1866,7 @@ var ClawOpsAgent = class {
1623
1866
  });
1624
1867
  if (resp.status !== 201) {
1625
1868
  const error = await resp.json();
1626
- throw new chunkMOH4FRJZ_cjs.AgentError(`\uBC1C\uC2E0 \uC2E4\uD328 (${resp.status}): ${error["error"] ?? ""}`);
1869
+ throw new chunkX6VG5NNG_cjs.AgentError(`\uBC1C\uC2E0 \uC2E4\uD328 (${resp.status}): ${error["error"] ?? ""}`);
1627
1870
  }
1628
1871
  const data = await resp.json();
1629
1872
  const callSession = new CallSession({
@@ -1802,6 +2045,7 @@ var ClawOpsAgent = class {
1802
2045
  },
1803
2046
  () => mediaWs.isConnected
1804
2047
  );
2048
+ session._transferFn = (params) => this._controlWs.requestTransfer(session.callId, params);
1805
2049
  const sessionHandler = this._session;
1806
2050
  if ("setToolRegistry" in sessionHandler && typeof sessionHandler.setToolRegistry === "function") {
1807
2051
  sessionHandler.setToolRegistry(sessionTools);
@@ -1815,6 +2059,9 @@ var ClawOpsAgent = class {
1815
2059
  if ("setLogger" in sessionHandler && typeof sessionHandler.setLogger === "function") {
1816
2060
  sessionHandler.setLogger(this._isPipelineSession ? this._pipelineLog : this._log);
1817
2061
  }
2062
+ if (this._holdAudioChunks && "setHoldAudio" in sessionHandler && typeof sessionHandler.setHoldAudio === "function") {
2063
+ sessionHandler.setHoldAudio(this._holdAudioChunks);
2064
+ }
1818
2065
  this._callSessions.set(session.callId, sessionHandler);
1819
2066
  mediaWs.onAudio((ulawAudio, _timestamp) => {
1820
2067
  if (sessionHandler) {
@@ -1916,10 +2163,27 @@ var SEND_DTMF = {
1916
2163
  required: ["digits"]
1917
2164
  }
1918
2165
  };
2166
+ var TRANSFER_CALL = {
2167
+ name: "transfer_call",
2168
+ description: "Transfer the current call to another phone number. Use for blind transfer (direct handoff) or warm transfer (with whisper message to the target).",
2169
+ parameters: {
2170
+ type: "object",
2171
+ properties: {
2172
+ to: { type: "string", description: "Phone number to transfer to" },
2173
+ mode: { type: "string", enum: ["blind", "warm"], description: "blind: direct transfer (default), warm: play whisper to target first" },
2174
+ after_transfer: { type: "string", enum: ["terminate", "return"], description: "terminate: end AI session (default), return: AI resumes after transfer ends" },
2175
+ whisper: { type: "string", description: "Message to speak to transfer target before connecting customer (warm mode only)" },
2176
+ caller_id: { type: "string", description: "Override caller ID for the transfer leg" },
2177
+ timeout: { type: "integer", description: "Seconds to wait for transfer target to answer (default 30)" }
2178
+ },
2179
+ required: ["to"]
2180
+ }
2181
+ };
1919
2182
  var TOOL_MAP = /* @__PURE__ */ new Map([
1920
2183
  ["hang_up" /* HANG_UP */, HANG_UP],
1921
2184
  ["collect_dtmf" /* COLLECT_DTMF */, COLLECT_DTMF],
1922
- ["send_dtmf" /* SEND_DTMF */, SEND_DTMF]
2185
+ ["send_dtmf" /* SEND_DTMF */, SEND_DTMF],
2186
+ ["transfer_call" /* TRANSFER_CALL */, TRANSFER_CALL]
1923
2187
  ]);
1924
2188
  var BUILTIN_TOOL_NAMES = new Set(
1925
2189
  Array.from(TOOL_MAP.values()).map((s) => s.name)
@@ -1992,6 +2256,21 @@ async function executeBuiltinTool(funcName, args, call) {
1992
2256
  return `Error: ${e}`;
1993
2257
  }
1994
2258
  }
2259
+ if (funcName === "transfer_call") {
2260
+ try {
2261
+ call.transfer(args["to"], {
2262
+ mode: args["mode"] ?? void 0,
2263
+ afterTransfer: args["after_transfer"] ?? void 0,
2264
+ whisper: args["whisper"] ?? void 0,
2265
+ callerId: args["caller_id"] ?? void 0,
2266
+ timeout: args["timeout"] ?? void 0
2267
+ }).catch(() => {
2268
+ });
2269
+ return JSON.stringify({ status: "transfer_initiated" });
2270
+ } catch (e) {
2271
+ return `Error: ${e}`;
2272
+ }
2273
+ }
1995
2274
  return null;
1996
2275
  }
1997
2276
 
@@ -2015,6 +2294,7 @@ var PipelineSession = class {
2015
2294
  _running = false;
2016
2295
  _speaking = false;
2017
2296
  _builtinTools = null;
2297
+ _holdAudioChunks = null;
2018
2298
  _log = NOOP_LOGGER;
2019
2299
  constructor(options) {
2020
2300
  this._stt = options.stt;
@@ -2039,6 +2319,10 @@ var PipelineSession = class {
2039
2319
  setBuiltinTools(tools) {
2040
2320
  this._builtinTools = tools;
2041
2321
  }
2322
+ /** Tool 실행 중 재생할 hold audio 청크를 설정한다. */
2323
+ setHoldAudio(chunks) {
2324
+ this._holdAudioChunks = chunks;
2325
+ }
2042
2326
  getTelemetry() {
2043
2327
  const llm = this._llm;
2044
2328
  const stt = this._stt;
@@ -2204,7 +2488,14 @@ var PipelineSession = class {
2204
2488
  }
2205
2489
  if (!this._tools) return;
2206
2490
  this._callSession?.recordToolCall();
2207
- const result = await this._tools.call(name, args);
2491
+ const player = this._holdAudioChunks && this._callSession ? new HoldAudioPlayer(this._callSession, this._holdAudioChunks) : null;
2492
+ player?.start();
2493
+ let result;
2494
+ try {
2495
+ result = await this._tools.call(name, args);
2496
+ } finally {
2497
+ player?.stop();
2498
+ }
2208
2499
  this._conversation.push({
2209
2500
  role: "assistant",
2210
2501
  content: ""
@@ -2316,6 +2607,8 @@ var OpenAIRealtime = class {
2316
2607
  // Response state tracking — prevent sending response.create while one is active
2317
2608
  _responseInProgress = false;
2318
2609
  _onResponseDone = null;
2610
+ // Hold audio — tool 실행 중 대기음
2611
+ _holdAudioChunks = null;
2319
2612
  constructor(options = {}) {
2320
2613
  this._apiKey = options.apiKey ?? process.env["OPENAI_API_KEY"] ?? "";
2321
2614
  this._systemPrompt = options.systemPrompt ?? "";
@@ -2333,6 +2626,10 @@ var OpenAIRealtime = class {
2333
2626
  setRecorder(recorder) {
2334
2627
  this._recorder = recorder;
2335
2628
  }
2629
+ /** Tool 실행 중 재생할 hold audio 청크를 설정한다. */
2630
+ setHoldAudio(chunks) {
2631
+ this._holdAudioChunks = chunks;
2632
+ }
2336
2633
  async start(callSession, tools) {
2337
2634
  this._call = callSession;
2338
2635
  if (tools) this._tools = tools;
@@ -2588,6 +2885,8 @@ var OpenAIRealtime = class {
2588
2885
  return;
2589
2886
  }
2590
2887
  let result;
2888
+ const player = this._holdAudioChunks && this._call ? new HoldAudioPlayer(this._call, this._holdAudioChunks) : null;
2889
+ player?.start();
2591
2890
  try {
2592
2891
  const args = JSON.parse(item["arguments"] ?? "{}");
2593
2892
  this._call?.recordToolCall();
@@ -2598,6 +2897,8 @@ var OpenAIRealtime = class {
2598
2897
  this._call?.recordToolError(err);
2599
2898
  }
2600
2899
  result = `Error: ${err}`;
2900
+ } finally {
2901
+ player?.stop();
2601
2902
  }
2602
2903
  if (controller.signal.aborted) {
2603
2904
  this._log.info("Tool call cancelled (user interrupted): %s", funcName);
@@ -2705,7 +3006,7 @@ function sanitizeSchemaForGemini(schema, defs, depth = 0) {
2705
3006
  if (result["type"] === "object" && !result["properties"]) result["properties"] = {};
2706
3007
  return result;
2707
3008
  }
2708
- var GeminiRealtime = class {
3009
+ var GeminiRealtime = class _GeminiRealtime {
2709
3010
  _apiKey;
2710
3011
  _systemPrompt;
2711
3012
  _model;
@@ -2721,12 +3022,15 @@ var GeminiRealtime = class {
2721
3022
  _sentAudioChunks = 0;
2722
3023
  _audioRemainder = Buffer.alloc(0);
2723
3024
  _builtinTools = null;
2724
- _toolCallInProgress = false;
3025
+ _pendingToolCall = null;
3026
+ _toolDrainTimer = null;
3027
+ _lastAudioTime = 0;
3028
+ _holdAudioChunks = null;
2725
3029
  _log = NOOP_LOGGER;
2726
3030
  constructor(options = {}) {
2727
3031
  this._apiKey = options.apiKey ?? process.env["GOOGLE_API_KEY"] ?? "";
2728
3032
  this._systemPrompt = options.systemPrompt ?? "";
2729
- this._model = options.model ?? "gemini-2.5-flash-native-audio-preview-12-2025";
3033
+ this._model = options.model ?? "gemini-3.1-flash-live-preview";
2730
3034
  this._voice = options.voice ?? "Kore";
2731
3035
  this._language = options.language ?? "ko";
2732
3036
  this._greeting = options.greeting ?? true;
@@ -2742,6 +3046,10 @@ var GeminiRealtime = class {
2742
3046
  setBuiltinTools(tools) {
2743
3047
  this._builtinTools = tools;
2744
3048
  }
3049
+ /** Tool 실행 중 재생할 hold audio 청크를 설정한다. */
3050
+ setHoldAudio(chunks) {
3051
+ this._holdAudioChunks = chunks;
3052
+ }
2745
3053
  setLogger(logger) {
2746
3054
  this._log = logger;
2747
3055
  }
@@ -2766,11 +3074,8 @@ var GeminiRealtime = class {
2766
3074
  this._closed = false;
2767
3075
  this._sentAudioChunks = 0;
2768
3076
  this._audioRemainder = Buffer.alloc(0);
2769
- if (!this._apiKey) {
2770
- throw new Error("Google API key is required. Set GOOGLE_API_KEY or pass apiKey option.");
2771
- }
2772
3077
  const { GoogleGenAI } = await import('@google/genai/node');
2773
- const client = new GoogleGenAI({ apiKey: this._apiKey });
3078
+ const client = this._apiKey ? new GoogleGenAI({ apiKey: this._apiKey }) : new GoogleGenAI({});
2774
3079
  const config = {
2775
3080
  responseModalities: ["AUDIO"],
2776
3081
  speechConfig: {
@@ -2784,7 +3089,9 @@ var GeminiRealtime = class {
2784
3089
  outputAudioTranscription: {}
2785
3090
  };
2786
3091
  if (this._systemPrompt) {
2787
- config["systemInstruction"] = this._systemPrompt;
3092
+ config["systemInstruction"] = {
3093
+ parts: [{ text: this._systemPrompt }]
3094
+ };
2788
3095
  }
2789
3096
  const toolSchemas = this._buildToolSchemas();
2790
3097
  if (toolSchemas.length > 0) {
@@ -2799,25 +3106,20 @@ var GeminiRealtime = class {
2799
3106
  this._log.error({ err }, "Gemini SDK error");
2800
3107
  },
2801
3108
  onclose: (ev) => {
2802
- this._log.info({ code: ev?.code ?? "unknown" }, "Gemini connection closed");
3109
+ this._log.info(
3110
+ { code: ev?.code ?? "unknown" },
3111
+ "Gemini connection closed"
3112
+ );
2803
3113
  this._closed = true;
2804
3114
  }
2805
3115
  }
2806
3116
  });
2807
3117
  if (this._greeting) {
2808
- this._session.sendClientContent({
2809
- turns: [
2810
- {
2811
- role: "user",
2812
- parts: [{ text: "\uC778\uC0AC\uD574 \uC8FC\uC138\uC694." }]
2813
- }
2814
- ],
2815
- turnComplete: true
2816
- });
3118
+ this._session.sendRealtimeInput({ text: "\uC778\uC0AC\uD574 \uC8FC\uC138\uC694." });
2817
3119
  }
2818
3120
  }
2819
3121
  feedAudio(audio) {
2820
- if (this._session && !this._closed && !this._toolCallInProgress) {
3122
+ if (this._session && !this._closed) {
2821
3123
  const pcm8k = ulawToPcm16(audio);
2822
3124
  if (this._recorder) {
2823
3125
  this._recorder.writeInbound(pcm8k);
@@ -2833,14 +3135,16 @@ var GeminiRealtime = class {
2833
3135
  }
2834
3136
  async feedDtmf(digits) {
2835
3137
  if (this._session) {
2836
- this._session.sendClientContent({
2837
- turns: [{ role: "user", parts: [{ text: `[DTMF \uC785\uB825: ${digits}]` }] }],
2838
- turnComplete: true
2839
- });
3138
+ this._session.sendRealtimeInput({ text: `[DTMF \uC785\uB825: ${digits}]` });
2840
3139
  }
2841
3140
  }
2842
3141
  async stop() {
2843
3142
  this._closed = true;
3143
+ if (this._toolDrainTimer) {
3144
+ clearTimeout(this._toolDrainTimer);
3145
+ this._toolDrainTimer = null;
3146
+ }
3147
+ this._pendingToolCall = null;
2844
3148
  if (this._session) {
2845
3149
  try {
2846
3150
  this._session.close();
@@ -2872,6 +3176,7 @@ var GeminiRealtime = class {
2872
3176
  const mimeType = inlineData.mimeType ?? "";
2873
3177
  if (mimeType.includes("audio")) {
2874
3178
  this._handleAudioData(inlineData.data);
3179
+ if (this._pendingToolCall) this._lastAudioTime = Date.now();
2875
3180
  }
2876
3181
  }
2877
3182
  }
@@ -2900,11 +3205,17 @@ var GeminiRealtime = class {
2900
3205
  }
2901
3206
  }
2902
3207
  if (msg.toolCall) {
2903
- this._handleToolCall(msg.toolCall);
3208
+ this._pendingToolCall = msg.toolCall;
3209
+ this._scheduleToolExecution();
2904
3210
  }
2905
3211
  const toolCancellation = msg["toolCallCancellation"];
2906
3212
  if (toolCancellation) {
2907
3213
  this._log.info({ ids: toolCancellation.ids }, "Tool call cancelled");
3214
+ this._pendingToolCall = null;
3215
+ if (this._toolDrainTimer) {
3216
+ clearTimeout(this._toolDrainTimer);
3217
+ this._toolDrainTimer = null;
3218
+ }
2908
3219
  }
2909
3220
  }
2910
3221
  _handleAudioData(b64Data) {
@@ -2935,54 +3246,81 @@ var GeminiRealtime = class {
2935
3246
  this._audioRemainder = Buffer.alloc(0);
2936
3247
  }
2937
3248
  }
3249
+ // Gemini는 tool_call 후에도 오디오를 계속 보내므로, 이 시간 내 응답이 없으면 drain 완료로 간주
3250
+ static TOOL_DRAIN_TIMEOUT = 300;
3251
+ _scheduleToolExecution() {
3252
+ if (this._toolDrainTimer) return;
3253
+ this._lastAudioTime = Date.now();
3254
+ this._toolDrainTimer = setTimeout(() => {
3255
+ this._toolDrainTimer = null;
3256
+ if (Date.now() - this._lastAudioTime < _GeminiRealtime.TOOL_DRAIN_TIMEOUT) {
3257
+ this._scheduleToolExecution();
3258
+ return;
3259
+ }
3260
+ if (this._pendingToolCall) {
3261
+ const tc = this._pendingToolCall;
3262
+ this._pendingToolCall = null;
3263
+ this._handleToolCall(tc);
3264
+ }
3265
+ }, _GeminiRealtime.TOOL_DRAIN_TIMEOUT);
3266
+ }
2938
3267
  async _handleToolCall(toolCall) {
2939
3268
  const functionCalls = toolCall.functionCalls;
2940
3269
  if (!functionCalls) return;
2941
- this._toolCallInProgress = true;
2942
3270
  const responses = [];
2943
- for (const fc of functionCalls) {
2944
- const name = fc.name ?? "";
2945
- const fcId = fc.id ?? "";
2946
- const args = fc.args ?? {};
2947
- this._log.info({ tool: name, args }, "Tool call: %s", name);
2948
- if (BUILTIN_TOOL_NAMES.has(name) && this._call) {
2949
- const result = await executeBuiltinTool(name, args, this._call);
2950
- if (result !== null) {
2951
- if (name === "hang_up") {
2952
- this._log.info("hang_up: ending call");
2953
- return;
3271
+ const player = this._holdAudioChunks && this._call ? new HoldAudioPlayer(this._call, this._holdAudioChunks) : null;
3272
+ player?.start();
3273
+ try {
3274
+ for (const fc of functionCalls) {
3275
+ const name = fc.name ?? "";
3276
+ const fcId = fc.id ?? "";
3277
+ const args = fc.args ?? {};
3278
+ this._log.info({ tool: name, args }, "Tool call: %s", name);
3279
+ if (BUILTIN_TOOL_NAMES.has(name) && this._call) {
3280
+ const result = await executeBuiltinTool(
3281
+ name,
3282
+ args,
3283
+ this._call
3284
+ );
3285
+ if (result !== null) {
3286
+ if (name === "hang_up") {
3287
+ this._log.info("hang_up: ending call");
3288
+ return;
3289
+ }
3290
+ this._log.info("Builtin tool result: %s -> %s", name, result);
3291
+ responses.push({ id: fcId, name, response: { result } });
3292
+ continue;
2954
3293
  }
2955
- this._log.info("Builtin tool result: %s -> %s", name, result);
2956
- responses.push({ id: fcId, name, response: { result } });
3294
+ }
3295
+ if (!this._tools || !this._tools.has(name)) {
3296
+ this._log.error("Unknown tool: %s", name);
3297
+ responses.push({ id: fcId, name, response: { error: `Unknown tool: ${name}` } });
2957
3298
  continue;
2958
3299
  }
2959
- }
2960
- if (!this._tools || !this._tools.has(name)) {
2961
- this._log.error("Unknown tool: %s", name);
2962
- responses.push({ id: fcId, name, response: { error: `Unknown tool: ${name}` } });
2963
- continue;
2964
- }
2965
- try {
2966
- this._call?.recordToolCall();
2967
- const result = await this._tools.call(name, args);
2968
- const resultStr = typeof result === "string" ? result : JSON.stringify(result);
2969
- this._log.info("Tool result: %s -> %s", name, resultStr.substring(0, 200));
2970
- responses.push({
2971
- id: fcId,
2972
- name,
2973
- response: { result: resultStr }
2974
- });
2975
- } catch (err) {
2976
- this._log.error({ err }, "Tool call failed: %s", name);
2977
- if (err instanceof Error) {
2978
- this._call?.recordToolError(err);
3300
+ try {
3301
+ this._call?.recordToolCall();
3302
+ const result = await this._tools.call(name, args);
3303
+ const resultStr = typeof result === "string" ? result : JSON.stringify(result);
3304
+ this._log.info("Tool result: %s -> %s", name, resultStr.substring(0, 200));
3305
+ responses.push({
3306
+ id: fcId,
3307
+ name,
3308
+ response: { result: resultStr }
3309
+ });
3310
+ } catch (err) {
3311
+ this._log.error({ err }, "Tool call failed: %s", name);
3312
+ if (err instanceof Error) {
3313
+ this._call?.recordToolError(err);
3314
+ }
3315
+ responses.push({
3316
+ id: fcId,
3317
+ name,
3318
+ response: { error: String(err) }
3319
+ });
2979
3320
  }
2980
- responses.push({
2981
- id: fcId,
2982
- name,
2983
- response: { error: String(err) }
2984
- });
2985
3321
  }
3322
+ } finally {
3323
+ player?.stop();
2986
3324
  }
2987
3325
  if (responses.length > 0 && this._session) {
2988
3326
  this._log.debug("Sending %d tool response(s)", responses.length);
@@ -2990,7 +3328,6 @@ var GeminiRealtime = class {
2990
3328
  functionResponses: responses
2991
3329
  });
2992
3330
  }
2993
- this._toolCallInProgress = false;
2994
3331
  }
2995
3332
  };
2996
3333
 
@@ -3505,9 +3842,8 @@ var GeminiLLM = class {
3505
3842
  }
3506
3843
  async *generate(messages, options) {
3507
3844
  const { GoogleGenAI } = await import('@google/genai');
3508
- const client = new GoogleGenAI({
3509
- apiKey: this._options.apiKey ?? process.env["GOOGLE_API_KEY"]
3510
- });
3845
+ const apiKey = this._options.apiKey ?? process.env["GOOGLE_API_KEY"];
3846
+ const client = apiKey ? new GoogleGenAI({ apiKey }) : new GoogleGenAI();
3511
3847
  let systemInstruction;
3512
3848
  const contents = [];
3513
3849
  for (const msg of messages) {