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