@teamlearners/clawops 0.16.0 → 0.16.4

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 chunkD25IRY5V_cjs = require('../chunk-D25IRY5V.cjs');
3
+ var chunkGTXJFMHN_cjs = require('../chunk-GTXJFMHN.cjs');
4
4
  var pino = require('pino');
5
5
  var fs = require('fs');
6
6
  var path = require('path');
@@ -331,6 +331,27 @@ function ulawToPcm16(ulaw) {
331
331
  }
332
332
  return out;
333
333
  }
334
+ function applyPcm16Gain(pcm, gain) {
335
+ if (!pcm.length) return Buffer.alloc(0);
336
+ if (gain === 1) return pcm;
337
+ if (!Number.isFinite(gain) || gain < 0) {
338
+ throw new Error("gain must be a finite number >= 0");
339
+ }
340
+ const nSamples = pcm.length >> 1;
341
+ const out = Buffer.alloc(nSamples * 2);
342
+ for (let i = 0; i < nSamples; i++) {
343
+ let value = Math.round(pcm.readInt16LE(i * 2) * gain);
344
+ if (value > 32767) value = 32767;
345
+ else if (value < -32768) value = -32768;
346
+ out.writeInt16LE(value, i * 2);
347
+ }
348
+ return out;
349
+ }
350
+ function applyUlawGain(ulaw, gain) {
351
+ if (!ulaw.length) return Buffer.alloc(0);
352
+ if (gain === 1) return ulaw;
353
+ return pcm16ToUlaw(applyPcm16Gain(ulawToPcm16(ulaw), gain));
354
+ }
334
355
  function resamplePcm16(pcm, fromRate, toRate) {
335
356
  if (fromRate === toRate || !pcm.length) return pcm;
336
357
  const nSamples = pcm.length >> 1;
@@ -919,8 +940,9 @@ var AudioRecorder = class {
919
940
  _inWritten = 0;
920
941
  _outWritten = 0;
921
942
  _mixWritten = 0;
922
- _startTime = 0;
923
943
  _started = false;
944
+ _baseTs = null;
945
+ _outCursor = 0;
924
946
  _log = NOOP_LOGGER;
925
947
  setLogger(logger) {
926
948
  this._log = logger;
@@ -937,17 +959,18 @@ var AudioRecorder = class {
937
959
  fs__namespace.writeSync(this._fdIn, header);
938
960
  fs__namespace.writeSync(this._fdOut, header);
939
961
  fs__namespace.writeSync(this._fdMix, header);
940
- this._startTime = performance.now();
941
962
  this._started = true;
942
963
  this._log.info("Recording started: %s", this._dir);
943
964
  }
944
- _expectedBytes() {
945
- const elapsed = (performance.now() - this._startTime) / 1e3;
946
- return Math.floor(elapsed * BYTES_PER_SECOND);
965
+ _timestampToBytes(mediaTsMs) {
966
+ if (this._baseTs === null) {
967
+ this._baseTs = mediaTsMs;
968
+ }
969
+ const target = Math.floor((mediaTsMs - this._baseTs) * BYTES_PER_SECOND / 1e3);
970
+ return Math.max(0, target - target % 2);
947
971
  }
948
- _padSilence(fd, written) {
949
- const expected = this._expectedBytes();
950
- let gap = expected - written;
972
+ _padSilence(fd, written, target) {
973
+ let gap = target - written;
951
974
  if (gap <= 0) return 0;
952
975
  gap = gap - gap % 2;
953
976
  if (gap > 0) {
@@ -957,9 +980,12 @@ var AudioRecorder = class {
957
980
  }
958
981
  _writeToMix(data, trackPos) {
959
982
  if (this._fdMix === null) return;
983
+ data = data.subarray(0, data.length - data.length % 2);
984
+ if (data.length === 0) return;
960
985
  const filePos = 44 + trackPos;
961
986
  if (trackPos < this._mixWritten) {
962
- const overlap = Math.min(data.length, this._mixWritten - trackPos);
987
+ let overlap = Math.min(data.length, this._mixWritten - trackPos);
988
+ overlap = overlap - overlap % 2;
963
989
  const existing = Buffer.alloc(overlap);
964
990
  fs__namespace.readSync(this._fdMix, existing, 0, overlap, filePos);
965
991
  const mixed = mixSamples(existing, data.subarray(0, overlap));
@@ -982,10 +1008,13 @@ var AudioRecorder = class {
982
1008
  this._mixWritten += data.length;
983
1009
  }
984
1010
  }
985
- writeInbound(pcm16_8k) {
1011
+ writeInbound(pcm16_8k, mediaTsMs = 0) {
986
1012
  if (!this._started || this._fdIn === null) return;
987
1013
  try {
988
- const gap = this._padSilence(this._fdIn, this._inWritten);
1014
+ pcm16_8k = pcm16_8k.subarray(0, pcm16_8k.length - pcm16_8k.length % 2);
1015
+ if (pcm16_8k.length === 0) return;
1016
+ const target = this._timestampToBytes(mediaTsMs);
1017
+ const gap = this._padSilence(this._fdIn, this._inWritten, target);
989
1018
  this._inWritten += gap;
990
1019
  const posBefore = this._inWritten;
991
1020
  fs__namespace.writeSync(this._fdIn, pcm16_8k);
@@ -995,14 +1024,21 @@ var AudioRecorder = class {
995
1024
  this._log.error({ err }, "Recording write error (inbound)");
996
1025
  }
997
1026
  }
998
- writeOutbound(pcm16_8k) {
1027
+ writeOutbound(pcm16_8k, mediaTsMs) {
999
1028
  if (!this._started || this._fdOut === null) return;
1000
1029
  try {
1001
- const gap = this._padSilence(this._fdOut, this._outWritten);
1030
+ pcm16_8k = pcm16_8k.subarray(0, pcm16_8k.length - pcm16_8k.length % 2);
1031
+ if (pcm16_8k.length === 0) return;
1032
+ let target = this._outCursor;
1033
+ if (mediaTsMs !== void 0) {
1034
+ target = Math.max(target, this._timestampToBytes(mediaTsMs));
1035
+ }
1036
+ const gap = this._padSilence(this._fdOut, this._outWritten, target);
1002
1037
  this._outWritten += gap;
1003
1038
  const posBefore = this._outWritten;
1004
1039
  fs__namespace.writeSync(this._fdOut, pcm16_8k);
1005
1040
  this._outWritten += pcm16_8k.length;
1041
+ this._outCursor = this._outWritten;
1006
1042
  this._writeToMix(pcm16_8k, posBefore);
1007
1043
  } catch (err) {
1008
1044
  this._log.error({ err }, "Recording write error (outbound)");
@@ -1043,7 +1079,7 @@ var MAX_ERROR_MESSAGE_LENGTH = 200;
1043
1079
  function getSdkInfo() {
1044
1080
  return {
1045
1081
  name: "clawops-node",
1046
- version: chunkD25IRY5V_cjs.VERSION,
1082
+ version: chunkGTXJFMHN_cjs.VERSION,
1047
1083
  runtime: `node/${process.versions.node}`,
1048
1084
  os: `${process.platform}/${os__default.default.arch()}`
1049
1085
  };
@@ -1698,7 +1734,7 @@ var ATTR_CALL_DIRECTION = "clawops.call.direction";
1698
1734
  var ATTR_AGENT_ID = "clawops.agent.id";
1699
1735
 
1700
1736
  // src/agent/agent.ts
1701
- var ClawOpsAgent = class {
1737
+ var ClawOpsAgent = class _ClawOpsAgent {
1702
1738
  _apiKey;
1703
1739
  _accountId;
1704
1740
  _baseUrl;
@@ -1721,10 +1757,12 @@ var ClawOpsAgent = class {
1721
1757
  _pipelineLog;
1722
1758
  _isPipelineSession = false;
1723
1759
  _holdAudioChunks = null;
1760
+ _rxGain;
1761
+ _txGain;
1724
1762
  constructor(options) {
1725
1763
  this._apiKey = options.apiKey ?? process.env["CLAWOPS_API_KEY"] ?? "";
1726
1764
  this._accountId = options.accountId ?? process.env["CLAWOPS_ACCOUNT_ID"] ?? "";
1727
- this._baseUrl = options.baseUrl ?? chunkD25IRY5V_cjs.DEFAULT_BASE_URL;
1765
+ this._baseUrl = options.baseUrl ?? chunkGTXJFMHN_cjs.DEFAULT_BASE_URL;
1728
1766
  this._fromNumber = options.from;
1729
1767
  this._session = options.session;
1730
1768
  this._recording = options.recording ?? false;
@@ -1732,6 +1770,8 @@ var ClawOpsAgent = class {
1732
1770
  this._mcpServers = options.mcpServers ?? [];
1733
1771
  this._builtinTools = resolveBuiltinTools(options.builtinTools ?? "all" /* ALL */);
1734
1772
  this._passiveDtmfDebounceMs = options.passiveDtmfDebounceMs ?? 500;
1773
+ this._rxGain = _ClawOpsAgent._validateGain("rxGain", options.rxGain ?? 1);
1774
+ this._txGain = _ClawOpsAgent._validateGain("txGain", options.txGain ?? 1);
1735
1775
  if (options.tracing) {
1736
1776
  setTracingConfig(options.tracing);
1737
1777
  }
@@ -1742,6 +1782,12 @@ var ClawOpsAgent = class {
1742
1782
  this._holdAudioChunks = loadHoldAudio(options.toolConfig.holdAudio);
1743
1783
  }
1744
1784
  }
1785
+ static _validateGain(name, gain) {
1786
+ if (typeof gain !== "number" || !Number.isFinite(gain) || gain < 0) {
1787
+ throw new chunkGTXJFMHN_cjs.AgentError(`${name}=${gain} must be a finite number >= 0`);
1788
+ }
1789
+ return gain;
1790
+ }
1745
1791
  /**
1746
1792
  * Register a function tool.
1747
1793
  *
@@ -1752,7 +1798,7 @@ var ClawOpsAgent = class {
1752
1798
  tool(nameOrTool, description, parameters, handler) {
1753
1799
  if (typeof nameOrTool === "string") {
1754
1800
  if (!description || !parameters || !handler) {
1755
- throw new chunkD25IRY5V_cjs.AgentError(
1801
+ throw new chunkGTXJFMHN_cjs.AgentError(
1756
1802
  "tool(name, description, parameters, handler) requires all arguments."
1757
1803
  );
1758
1804
  }
@@ -1788,10 +1834,10 @@ var ClawOpsAgent = class {
1788
1834
  async connect() {
1789
1835
  if (this._controlWs) return;
1790
1836
  if (!this._apiKey) {
1791
- throw new chunkD25IRY5V_cjs.AgentError("API key is required. Set CLAWOPS_API_KEY or pass apiKey option.");
1837
+ throw new chunkGTXJFMHN_cjs.AgentError("API key is required. Set CLAWOPS_API_KEY or pass apiKey option.");
1792
1838
  }
1793
1839
  if (!this._accountId) {
1794
- throw new chunkD25IRY5V_cjs.AgentError(
1840
+ throw new chunkGTXJFMHN_cjs.AgentError(
1795
1841
  "Account ID is required. Set CLAWOPS_ACCOUNT_ID or pass accountId option."
1796
1842
  );
1797
1843
  }
@@ -1815,7 +1861,7 @@ var ClawOpsAgent = class {
1815
1861
  } catch {
1816
1862
  }
1817
1863
  } catch (err) {
1818
- throw new chunkD25IRY5V_cjs.AgentConnectionError(
1864
+ throw new chunkGTXJFMHN_cjs.AgentConnectionError(
1819
1865
  `Failed to connect to ClawOps: ${err instanceof Error ? err.message : String(err)}`
1820
1866
  );
1821
1867
  }
@@ -1866,7 +1912,7 @@ var ClawOpsAgent = class {
1866
1912
  });
1867
1913
  if (resp.status !== 201) {
1868
1914
  const error = await resp.json();
1869
- throw new chunkD25IRY5V_cjs.AgentError(`\uBC1C\uC2E0 \uC2E4\uD328 (${resp.status}): ${error["error"] ?? ""}`);
1915
+ throw new chunkGTXJFMHN_cjs.AgentError(`\uBC1C\uC2E0 \uC2E4\uD328 (${resp.status}): ${error["error"] ?? ""}`);
1870
1916
  }
1871
1917
  const data = await resp.json();
1872
1918
  const callSession = new CallSession({
@@ -1909,9 +1955,7 @@ var ClawOpsAgent = class {
1909
1955
  this._controlWs.send({ event: "call.accept", callId });
1910
1956
  }
1911
1957
  if (mediaUrl) {
1912
- this._startCallSession(session, mediaUrl).catch((err) => {
1913
- this._log.error({ err }, "Call session error: %s", callId);
1914
- });
1958
+ this._safeStartCallSession(session, mediaUrl, callId);
1915
1959
  }
1916
1960
  }
1917
1961
  _handleEnded(event) {
@@ -1945,9 +1989,7 @@ var ClawOpsAgent = class {
1945
1989
  }
1946
1990
  if (mediaUrl) {
1947
1991
  this._log.info("Outbound call answered: %s -> %s (%s)", this._fromNumber, session.toNumber, callId);
1948
- this._startCallSession(session, mediaUrl).catch((err) => {
1949
- this._log.error({ err }, "Call session error: %s", callId);
1950
- });
1992
+ this._safeStartCallSession(session, mediaUrl, callId);
1951
1993
  }
1952
1994
  }
1953
1995
  _handleRinging(event) {
@@ -1991,6 +2033,32 @@ var ClawOpsAgent = class {
1991
2033
  }
1992
2034
  }, this._passiveDtmfDebounceMs);
1993
2035
  }
2036
+ /**
2037
+ * _startCallSession 의 예외를 잡아 control WS 로 call.session_failed 전송한다.
2038
+ *
2039
+ * OpenAI/Gemini API 키 누락 등 session.start() 단계 실패는 media WS connect 에
2040
+ * 도달하지 못해 call-engine 이 30 초간 무음 통화를 유지하게 만든다. 서버에 즉시
2041
+ * 알려서 fail-fast 시키고 _activeSessions 에서 정리한다.
2042
+ */
2043
+ _safeStartCallSession(session, mediaWsUrl, callId) {
2044
+ this._startCallSession(session, mediaWsUrl).catch((err) => {
2045
+ const error = err;
2046
+ this._log.error({ err }, "Session start failed for %s", callId);
2047
+ if (this._controlWs) {
2048
+ try {
2049
+ this._controlWs.send({
2050
+ event: "call.session_failed",
2051
+ callId,
2052
+ reason: error?.name ?? "Error",
2053
+ message: error?.message ?? String(err)
2054
+ });
2055
+ } catch {
2056
+ }
2057
+ }
2058
+ this._activeSessions.delete(callId);
2059
+ this._callSessions.delete(callId);
2060
+ });
2061
+ }
1994
2062
  async _startCallSession(session, mediaWsUrl) {
1995
2063
  await withSpan(
1996
2064
  "clawops.call_session",
@@ -2024,9 +2092,14 @@ var ClawOpsAgent = class {
2024
2092
  }
2025
2093
  const mediaWs = new MediaWebSocket();
2026
2094
  mediaWs.setLogger(this._log);
2095
+ let latestMediaTs = 0;
2027
2096
  session._bindTransport(
2028
2097
  (audio) => {
2029
- mediaWs.sendAudio(audio.toString("base64"));
2098
+ const gained = applyUlawGain(audio, this._txGain);
2099
+ if (recorder) {
2100
+ recorder.writeOutbound(ulawToPcm16(gained), latestMediaTs);
2101
+ }
2102
+ mediaWs.sendAudio(gained.toString("base64"));
2030
2103
  session.recordFirstResponse();
2031
2104
  },
2032
2105
  () => {
@@ -2063,12 +2136,14 @@ var ClawOpsAgent = class {
2063
2136
  sessionHandler.setHoldAudio(this._holdAudioChunks);
2064
2137
  }
2065
2138
  this._callSessions.set(session.callId, sessionHandler);
2066
- mediaWs.onAudio((ulawAudio, _timestamp) => {
2067
- if (sessionHandler) {
2068
- sessionHandler.feedAudio(ulawAudio);
2069
- }
2139
+ mediaWs.onAudio((ulawAudio, timestamp) => {
2140
+ latestMediaTs = timestamp;
2141
+ const gained = applyUlawGain(ulawAudio, this._rxGain);
2070
2142
  if (recorder) {
2071
- recorder.writeInbound(ulawToPcm16(ulawAudio));
2143
+ recorder.writeInbound(ulawToPcm16(gained), timestamp);
2144
+ }
2145
+ if (sessionHandler) {
2146
+ sessionHandler.feedAudio(gained, timestamp);
2072
2147
  }
2073
2148
  });
2074
2149
  mediaWs.onDtmf((digit) => {
@@ -2539,10 +2614,6 @@ var PipelineSession = class {
2539
2614
  sampleRate: this._sampleRate
2540
2615
  })) {
2541
2616
  if (!this._running || !this._speaking) break;
2542
- if (this._recorder) {
2543
- const pcm8k2 = this._sampleRate !== 8e3 ? resamplePcm16(audioChunk, this._sampleRate, 8e3) : audioChunk;
2544
- this._recorder.writeOutbound(pcm8k2);
2545
- }
2546
2617
  const pcm8k = resamplePcm16(audioChunk, this._sampleRate, 8e3);
2547
2618
  const ulaw = pcm16ToUlaw(pcm8k);
2548
2619
  for (let off = 0; off < ulaw.length; off += 160) {
@@ -2611,8 +2682,13 @@ var OpenAIRealtime = class {
2611
2682
  _holdAudioChunks = null;
2612
2683
  constructor(options = {}) {
2613
2684
  this._apiKey = options.apiKey ?? process.env["OPENAI_API_KEY"] ?? "";
2685
+ if (!this._apiKey) {
2686
+ throw new Error(
2687
+ "OpenAI API key is required. Set OPENAI_API_KEY env var or pass apiKey option."
2688
+ );
2689
+ }
2614
2690
  this._systemPrompt = options.systemPrompt ?? "";
2615
- this._model = options.model ?? "gpt-realtime-1.5";
2691
+ this._model = options.model ?? "gpt-realtime-2";
2616
2692
  this._voice = options.voice ?? "marin";
2617
2693
  this._language = options.language ?? "ko";
2618
2694
  this._turnDetection = options.turnDetection !== void 0 ? options.turnDetection : { type: "semantic_vad", eagerness: "medium", interrupt_response: true };
@@ -2636,9 +2712,6 @@ var OpenAIRealtime = class {
2636
2712
  this._closed = false;
2637
2713
  this._playback = null;
2638
2714
  this._latestMediaTs = 0;
2639
- if (!this._apiKey) {
2640
- throw new Error("OpenAI API key is required. Set OPENAI_API_KEY or pass apiKey option.");
2641
- }
2642
2715
  const { WebSocket } = await import('ws');
2643
2716
  const url = `${OPENAI_REALTIME_URL}${this._model}`;
2644
2717
  this._ws = new WebSocket(url, {
@@ -2687,8 +2760,8 @@ var OpenAIRealtime = class {
2687
2760
  });
2688
2761
  this._send({ type: "response.create" });
2689
2762
  }
2690
- feedAudio(audio) {
2691
- this._latestMediaTs = Date.now();
2763
+ feedAudio(audio, timestamp) {
2764
+ this._latestMediaTs = timestamp ?? this._latestMediaTs;
2692
2765
  if (this._ws && this._ws.readyState === 1 && !this._closed) {
2693
2766
  this._send({
2694
2767
  type: "input_audio_buffer.append",
@@ -2799,7 +2872,7 @@ var OpenAIRealtime = class {
2799
2872
  if (this._playback === null) {
2800
2873
  this._playback = {
2801
2874
  itemId: msg["item_id"] || "",
2802
- startTs: this._latestMediaTs || Date.now(),
2875
+ startTs: this._latestMediaTs,
2803
2876
  sentChunks: 0,
2804
2877
  generating: true,
2805
2878
  audioRemainder: Buffer.alloc(0)
@@ -2809,9 +2882,6 @@ var OpenAIRealtime = class {
2809
2882
  }
2810
2883
  const pb = this._playback;
2811
2884
  const ulaw = Buffer.from(msg["delta"], "base64");
2812
- if (this._recorder) {
2813
- this._recorder.writeOutbound(ulawToPcm16(ulaw));
2814
- }
2815
2885
  const combined = Buffer.concat([pb.audioRemainder, ulaw]);
2816
2886
  const chunkSize = 160;
2817
2887
  const fullEnd = Math.floor(combined.length / chunkSize) * chunkSize;
@@ -2835,7 +2905,7 @@ var OpenAIRealtime = class {
2835
2905
  if (pb === null) {
2836
2906
  return;
2837
2907
  }
2838
- const playedMs = Math.max(0, (this._latestMediaTs || Date.now()) - pb.startTs);
2908
+ const playedMs = Math.max(0, this._latestMediaTs - pb.startTs);
2839
2909
  this._log.info(
2840
2910
  "[Interrupt] item=%s played=%dms total=%dms",
2841
2911
  pb.itemId,
@@ -3127,9 +3197,6 @@ var GeminiRealtime = class _GeminiRealtime {
3127
3197
  feedAudio(audio) {
3128
3198
  if (this._session && !this._closed) {
3129
3199
  const pcm8k = ulawToPcm16(audio);
3130
- if (this._recorder) {
3131
- this._recorder.writeInbound(pcm8k);
3132
- }
3133
3200
  const pcm16k = resamplePcm16(pcm8k, 8e3, 16e3);
3134
3201
  this._session.sendRealtimeInput({
3135
3202
  audio: {
@@ -3227,9 +3294,6 @@ var GeminiRealtime = class _GeminiRealtime {
3227
3294
  _handleAudioData(b64Data) {
3228
3295
  if (!this._call) return;
3229
3296
  const pcm24k = Buffer.from(b64Data, "base64");
3230
- if (this._recorder) {
3231
- this._recorder.writeOutbound(resamplePcm16(pcm24k, 24e3, 8e3));
3232
- }
3233
3297
  const pcm8k = resamplePcm16(pcm24k, 24e3, 8e3);
3234
3298
  const ulaw = pcm16ToUlaw(pcm8k);
3235
3299
  const combined = Buffer.concat([this._audioRemainder, ulaw]);