@oberik/sdk 0.59.0 → 0.61.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.
- package/dist/cjs/index.js +210 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.d.ts +965 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +210 -1
- package/dist/esm/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cjs/index.js
CHANGED
|
@@ -1719,7 +1719,14 @@ class AgentFramework {
|
|
|
1719
1719
|
* Mint a narrower token from the current credential — for a backend holding a
|
|
1720
1720
|
* project key that hands short-lived, per-end-user tokens to its frontend. Every
|
|
1721
1721
|
* field is intersected/clamped with what the caller already holds, so this can only
|
|
1722
|
-
* ever narrow: a restricted token cannot mint a broader one.
|
|
1722
|
+
* ever narrow: a restricted token cannot mint a broader one. A bound the caller holds
|
|
1723
|
+
* applies whether or not you name the field, because an absent claim reads as
|
|
1724
|
+
* *unrestricted*.
|
|
1725
|
+
*
|
|
1726
|
+
* `expires_in` is the one exception, deliberately: it is clamped against the PROJECT's
|
|
1727
|
+
* maximum rather than the caller's remaining lifetime, so a short-lived token can mint
|
|
1728
|
+
* a longer-lived one. It is the same grant for longer, never a wider one — but size a
|
|
1729
|
+
* token's lifetime by what it may do, not by whatever minted it.
|
|
1723
1730
|
*/
|
|
1724
1731
|
auth = {
|
|
1725
1732
|
token: (body = {}) => this.request("POST", "/auth/token", { body }),
|
|
@@ -2089,6 +2096,208 @@ class AgentFramework {
|
|
|
2089
2096
|
}),
|
|
2090
2097
|
},
|
|
2091
2098
|
};
|
|
2099
|
+
// -- voice ---------------------------------------------------------------
|
|
2100
|
+
/**
|
|
2101
|
+
* Live voice calls.
|
|
2102
|
+
*
|
|
2103
|
+
* `open()` creates the call and connects the audio socket; everything after that is
|
|
2104
|
+
* `send()` for microphone audio, `onAudio` for what to play, and events for what is
|
|
2105
|
+
* happening. The token is the same end-user JWT everything else uses, and the turns
|
|
2106
|
+
* behind the call are ordinary turns — same capability gate, same tool allow-list, same
|
|
2107
|
+
* per-customer cost attribution.
|
|
2108
|
+
*
|
|
2109
|
+
* Needs a `WebSocket` global: present in browsers and in Node 22+. On older Node, pass
|
|
2110
|
+
* one in as `webSocket` — the SDK deliberately has no dependencies, so it will not bring
|
|
2111
|
+
* an implementation of its own.
|
|
2112
|
+
*/
|
|
2113
|
+
voice = {
|
|
2114
|
+
open: async (body = {}, handlers = {}, opts = {}) => {
|
|
2115
|
+
const info = await this.request("POST", "/voice/sessions", { body });
|
|
2116
|
+
return this.connectVoice(info, handlers, opts.webSocket);
|
|
2117
|
+
},
|
|
2118
|
+
/** Reconnect to a call that is already open — a page reload, a second screen watching.
|
|
2119
|
+
*
|
|
2120
|
+
* Audio goes to whoever is connected; events go to everyone. So a supervisor can watch
|
|
2121
|
+
* a call without taking it over, which is what a call-centre floor actually needs. */
|
|
2122
|
+
attach: async (id, handlers = {}, opts = {}) => {
|
|
2123
|
+
const info = await this.request("GET", `/voice/sessions/${id}`);
|
|
2124
|
+
return this.connectVoice(info, handlers, opts.webSocket);
|
|
2125
|
+
},
|
|
2126
|
+
/** Every call this token can see. */
|
|
2127
|
+
list: () => this.request("GET", "/voice/sessions"),
|
|
2128
|
+
/** What the frontend can do — the voices and roles a project may ask for. Read it rather
|
|
2129
|
+
* than hard-coding a list: asking for a voice the host does not have fails the call at
|
|
2130
|
+
* `open()`, which is a worse place to find out than a dropdown. */
|
|
2131
|
+
frontend: () => this.request("GET", "/voice/frontend"),
|
|
2132
|
+
};
|
|
2133
|
+
connectVoice(info, handlers, webSocketImpl) {
|
|
2134
|
+
const WS = (webSocketImpl ?? globalThis.WebSocket);
|
|
2135
|
+
if (!WS) {
|
|
2136
|
+
throw new AgentStreamError("no WebSocket available. Browsers and Node 22+ have one; on older Node pass " +
|
|
2137
|
+
"`{ webSocket: WebSocket }` from a library of your choice. The SDK has no " +
|
|
2138
|
+
"dependencies and will not choose one for you.");
|
|
2139
|
+
}
|
|
2140
|
+
const base = this.baseUrl.replace(/^http/, "ws");
|
|
2141
|
+
const url = info.stream_url.startsWith("ws")
|
|
2142
|
+
? info.stream_url
|
|
2143
|
+
: `${base}${info.stream_url}`;
|
|
2144
|
+
const ws = new WS(url);
|
|
2145
|
+
ws.binaryType = "arraybuffer";
|
|
2146
|
+
const listeners = new Map();
|
|
2147
|
+
// Audio the caller produced before the socket finished opening. Buffered rather than
|
|
2148
|
+
// dropped: a browser's microphone starts before the connection does, and dropping the
|
|
2149
|
+
// first quarter-second means the caller's first word is missing — which reads as the
|
|
2150
|
+
// agent not listening.
|
|
2151
|
+
const pending = [];
|
|
2152
|
+
let closed = false;
|
|
2153
|
+
ws.onopen = () => {
|
|
2154
|
+
// The token goes in the first frame rather than in the URL. A query-string token ends
|
|
2155
|
+
// up in proxy logs and in browser history, and a WebSocket cannot carry an
|
|
2156
|
+
// Authorization header from a browser.
|
|
2157
|
+
void (async () => {
|
|
2158
|
+
const headers = await this.authHeaders();
|
|
2159
|
+
const bearer = headers["Authorization"] ?? headers["X-API-Key"] ?? "";
|
|
2160
|
+
ws.send(JSON.stringify({ type: "auth", token: bearer.replace(/^Bearer /, "") }));
|
|
2161
|
+
for (const chunk of pending)
|
|
2162
|
+
ws.send(chunk);
|
|
2163
|
+
pending.length = 0;
|
|
2164
|
+
})();
|
|
2165
|
+
};
|
|
2166
|
+
ws.onmessage = (e) => {
|
|
2167
|
+
if (typeof e.data !== "string") {
|
|
2168
|
+
handlers.onAudio?.(e.data);
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
let frame;
|
|
2172
|
+
try {
|
|
2173
|
+
frame = JSON.parse(e.data);
|
|
2174
|
+
}
|
|
2175
|
+
catch {
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
handlers.onEvent?.(frame);
|
|
2179
|
+
if (frame.event === "state")
|
|
2180
|
+
handlers.onState?.(frame.data);
|
|
2181
|
+
if (frame.event === "error")
|
|
2182
|
+
handlers.onError?.(frame.data.detail);
|
|
2183
|
+
for (const fn of listeners.get(frame.event) ?? [])
|
|
2184
|
+
fn(frame.data);
|
|
2185
|
+
};
|
|
2186
|
+
ws.onerror = () => handlers.onError?.("the voice socket failed");
|
|
2187
|
+
ws.onclose = (e) => {
|
|
2188
|
+
closed = true;
|
|
2189
|
+
handlers.onClose?.(e?.reason ?? "closed");
|
|
2190
|
+
};
|
|
2191
|
+
const post = async (path, body) => {
|
|
2192
|
+
await this.request("POST", `/voice/sessions/${info.id}${path}`, { body: body ?? {} });
|
|
2193
|
+
};
|
|
2194
|
+
/** The same call, keeping the answer. For the routes whose answer is the point — a
|
|
2195
|
+
* disclosure the server may have refused to say, and the reason it did. */
|
|
2196
|
+
const postFor = (path, body) => this.request("POST", `/voice/sessions/${info.id}${path}`, { body: body ?? {} });
|
|
2197
|
+
/** A control frame down the socket rather than an HTTP call.
|
|
2198
|
+
*
|
|
2199
|
+
* Because these are about *timing*: "the caller started talking" has to reach the server
|
|
2200
|
+
* in the same tens of milliseconds the audio does, and an HTTP round trip on the barge-in
|
|
2201
|
+
* path is the difference between the agent stopping and the agent talking over somebody.
|
|
2202
|
+
* Hold and transfer go over HTTP precisely because they are not on that path. */
|
|
2203
|
+
const control = (msg) => {
|
|
2204
|
+
if (ws.readyState === 1)
|
|
2205
|
+
ws.send(JSON.stringify(msg));
|
|
2206
|
+
};
|
|
2207
|
+
return {
|
|
2208
|
+
id: info.id,
|
|
2209
|
+
sessionId: info.session_id,
|
|
2210
|
+
rate: info.rate,
|
|
2211
|
+
info,
|
|
2212
|
+
get closed() {
|
|
2213
|
+
return closed;
|
|
2214
|
+
},
|
|
2215
|
+
send: (pcm) => {
|
|
2216
|
+
const bytes = pcm instanceof ArrayBuffer
|
|
2217
|
+
? new Uint8Array(pcm)
|
|
2218
|
+
: new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
|
|
2219
|
+
// 1 === OPEN. Compared numerically because a polyfilled WebSocket may not expose
|
|
2220
|
+
// the static constants, and `ws.OPEN` on an instance is not in the spec.
|
|
2221
|
+
if (ws.readyState === 1)
|
|
2222
|
+
ws.send(bytes);
|
|
2223
|
+
else if (!closed)
|
|
2224
|
+
pending.push(bytes);
|
|
2225
|
+
},
|
|
2226
|
+
on: (name, fn) => {
|
|
2227
|
+
const set = listeners.get(name) ?? new Set();
|
|
2228
|
+
listeners.set(name, set);
|
|
2229
|
+
set.add(fn);
|
|
2230
|
+
return () => set.delete(fn);
|
|
2231
|
+
},
|
|
2232
|
+
snapshot: () => this.request("GET", `/voice/sessions/${info.id}/state`),
|
|
2233
|
+
hold: (o = {}) => post("/hold", o),
|
|
2234
|
+
resume: (o = {}) => post("/resume", o),
|
|
2235
|
+
holdUntil: async (work, o = {}) => {
|
|
2236
|
+
// The hold goes on FIRST, and the work starts after. The other order leaves a window
|
|
2237
|
+
// where the work is running and the caller is hearing nothing — short for a fast
|
|
2238
|
+
// lookup, and exactly as long as the request takes for a slow one, which is the case
|
|
2239
|
+
// this whole mechanism is for.
|
|
2240
|
+
const maxSeconds = o.maxSeconds ?? 120;
|
|
2241
|
+
await post("/hold", {
|
|
2242
|
+
reason: o.reason ?? "the app is waiting on something",
|
|
2243
|
+
listen: o.listen,
|
|
2244
|
+
max_seconds: maxSeconds,
|
|
2245
|
+
});
|
|
2246
|
+
// The caller ending the hold themselves. Watched rather than polled, so "the caller
|
|
2247
|
+
// said never mind" and "the CRM answered" are a genuine race.
|
|
2248
|
+
let onInterrupted;
|
|
2249
|
+
const interrupted = new Promise((resolve) => {
|
|
2250
|
+
onInterrupted = () => resolve("interrupted");
|
|
2251
|
+
});
|
|
2252
|
+
const offHold = listeners.get("hold.interrupted") ?? new Set();
|
|
2253
|
+
listeners.set("hold.interrupted", offHold);
|
|
2254
|
+
const watcher = () => onInterrupted?.();
|
|
2255
|
+
offHold.add(watcher);
|
|
2256
|
+
let timer;
|
|
2257
|
+
const expired = new Promise((resolve) => {
|
|
2258
|
+
timer = setTimeout(() => resolve("timeout"), maxSeconds * 1000);
|
|
2259
|
+
});
|
|
2260
|
+
try {
|
|
2261
|
+
const raced = await Promise.race([
|
|
2262
|
+
work().then((result) => ({ outcome: "done", result }), (error) => ({ outcome: "failed", error })),
|
|
2263
|
+
interrupted.then(() => ({ outcome: "interrupted" })),
|
|
2264
|
+
expired.then(() => ({ outcome: "timeout" })),
|
|
2265
|
+
]);
|
|
2266
|
+
// Only resume for the outcomes where the caller is still waiting on us. An
|
|
2267
|
+
// interruption already took them off hold — resuming again would say "thanks for
|
|
2268
|
+
// waiting" to somebody who just said never mind.
|
|
2269
|
+
if (raced.outcome !== "interrupted") {
|
|
2270
|
+
await post("/resume", { lead: o.lead });
|
|
2271
|
+
}
|
|
2272
|
+
return raced;
|
|
2273
|
+
}
|
|
2274
|
+
finally {
|
|
2275
|
+
if (timer !== undefined)
|
|
2276
|
+
clearTimeout(timer);
|
|
2277
|
+
offHold.delete(watcher);
|
|
2278
|
+
}
|
|
2279
|
+
},
|
|
2280
|
+
transfer: (to, context) => post("/transfer", { to, context }),
|
|
2281
|
+
inject: (facts) => post("/context", { facts }),
|
|
2282
|
+
say: (text, o = {}) => postFor("/speech", { text, interrupt: o.interrupt ?? true }),
|
|
2283
|
+
speechStart: () => control({ type: "speech_start" }),
|
|
2284
|
+
speechEnd: () => control({ type: "speech_end" }),
|
|
2285
|
+
transcript: (text, final = true) => control({ type: "transcript", text, final }),
|
|
2286
|
+
dtmf: (digit) => control({ type: "dtmf", digit }),
|
|
2287
|
+
hangUp: () => control({ type: "hangup" }),
|
|
2288
|
+
markPlayed: (mark) => control({ type: "mark", mark }),
|
|
2289
|
+
bench: () => this.request("GET", `/voice/sessions/${info.id}/bench`),
|
|
2290
|
+
close: () => {
|
|
2291
|
+
closed = true;
|
|
2292
|
+
try {
|
|
2293
|
+
ws.close();
|
|
2294
|
+
}
|
|
2295
|
+
catch {
|
|
2296
|
+
/* already gone */
|
|
2297
|
+
}
|
|
2298
|
+
},
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
2092
2301
|
// -- documents -----------------------------------------------------------
|
|
2093
2302
|
/** Agent Plugins — the skills this token can reach for, and the ones it may add.
|
|
2094
2303
|
*
|