callkaro-cli-unofficial 0.9.4 → 0.10.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.
@@ -0,0 +1,361 @@
1
+ "use strict";
2
+ /**
3
+ * Live test calls: the agent editor's "Test Chat" tab, from a terminal.
4
+ *
5
+ * Read out of the dashboard bundle (no upstream CLI has this):
6
+ * 1. POST {base}/outbound_call?is_test=true&chat=true[&version=] with {userId, agent_id, metadata}
7
+ * returns {token, call_sid}. The request carries no credential: the server only checks that
8
+ * `Origin` is the dashboard's (403 "Caller not allowed" otherwise), so it is sent here too.
9
+ * 2. Join the LiveKit room at wss://livekit-inbound.callkaro.ai with that token.
10
+ * 3. User turns go out as text on topic `lk.chat`. Agent text comes back as `lk.transcription`
11
+ * text streams (LiveKit Agents 1.x), function calls as data packets on `lk.history`, the
12
+ * active node as the agent participant's `current_capability` attribute, and the agent's
13
+ * turn state as `lk.agent.state`. `lk.history` also carries a `{type:"message"}` item per
14
+ * finished message, which is what marks an agent turn complete: seen live, `lk.agent.state`
15
+ * returns to `listening` up to 1.2s before the transcript of that turn arrives.
16
+ *
17
+ * This is the real call runtime, not a simulation: every function runs for real and the call
18
+ * lands in call history with post-call analysis.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.END_MARK = exports.LLM_PRESETS = exports.LiveSession = exports.deps = exports.TEST_IDENTITY = exports.LIVEKIT_URL = void 0;
22
+ exports.testCallBase = testCallBase;
23
+ exports.requestTestToken = requestTestToken;
24
+ exports.formatTranscript = formatTranscript;
25
+ exports.resolveLlm = resolveLlm;
26
+ exports.chatComplete = chatComplete;
27
+ exports.callerSystemPrompt = callerSystemPrompt;
28
+ exports.parseCallerLine = parseCallerLine;
29
+ exports.judgeMessages = judgeMessages;
30
+ exports.parseVerdict = parseVerdict;
31
+ const fs_1 = require("fs");
32
+ const config_1 = require("./config");
33
+ exports.LIVEKIT_URL = 'wss://livekit-inbound.callkaro.ai';
34
+ exports.TEST_IDENTITY = 'test_participant';
35
+ // ponytail: copied from the dashboard bundle's host picker; re-read the bundle if a Spinny
36
+ // account's test call 404s. CK_TEST_CALL_URL overrides the host for every account.
37
+ const SPINNY_USER_IDS = new Set([
38
+ '681b6d70d73a629e2f9f9b5b', '683761f141599a2ac50570c7', '684980f1ad348c30b0155cc3',
39
+ '686eb5e262c7062950b41a23', '686eb58c62c7062950b41a20', '68e4e4da4a8835a2360e8aa6',
40
+ '6915a152a205df5fd2b576d7', '691c3d20409942e2d0492f76', '698dfcedbbb4b9d3496cb860',
41
+ '6a06f48caabb1c14cb9311b4',
42
+ ]);
43
+ function testCallBase(userId) {
44
+ if (process.env.CK_TEST_CALL_URL)
45
+ return process.env.CK_TEST_CALL_URL.replace(/\/+$/, '');
46
+ return SPINNY_USER_IDS.has(userId) ? 'https://spinny.callkaro.ai' : 'https://api.callkaro.ai';
47
+ }
48
+ async function requestTestToken(input) {
49
+ let url = `${testCallBase(input.userId)}/outbound_call?is_test=true&chat=true`;
50
+ if (input.version)
51
+ url += `&version=${encodeURIComponent(input.version)}`;
52
+ const res = await fetch(url, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json', Origin: (0, config_1.webUrl)(), Referer: `${(0, config_1.webUrl)()}/` },
55
+ body: JSON.stringify({ userId: input.userId, agent_id: input.agentId, metadata: input.metadata || {} }),
56
+ signal: AbortSignal.timeout(30_000),
57
+ });
58
+ const body = await res.text();
59
+ let data = {};
60
+ try {
61
+ data = JSON.parse(body);
62
+ }
63
+ catch { /* not JSON */ }
64
+ if (res.status !== 200) {
65
+ throw new Error(`Starting the test call failed (HTTP ${res.status}): ${data.message || body.slice(0, 300) || 'no body'}`);
66
+ }
67
+ if (!data.token)
68
+ throw new Error('The test-call server answered without a LiveKit token, so there is no room to join.');
69
+ return { token: data.token, callSid: data.call_sid };
70
+ }
71
+ /** Swappable for tests: the native LiveKit module is loaded only when a test call starts. */
72
+ exports.deps = {
73
+ rtc: () => {
74
+ try {
75
+ return require('@livekit/rtc-node');
76
+ }
77
+ catch {
78
+ throw new Error('Live tests need @livekit/rtc-node, which did not install on this machine. '
79
+ + 'Run: npm install --global @livekit/rtc-node, or reinstall cku on a supported platform '
80
+ + '(Linux, macOS or Windows, x64 or arm64).');
81
+ }
82
+ },
83
+ };
84
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
85
+ class LiveSession {
86
+ callSid;
87
+ events = [];
88
+ ended = false;
89
+ room;
90
+ agentState = '';
91
+ sawState = false;
92
+ sawHistory = false;
93
+ agentMessages = 0;
94
+ openStreams = 0;
95
+ lastActivity = Date.now();
96
+ lastSent = '';
97
+ segments = new Map();
98
+ constructor(callSid) {
99
+ this.callSid = callSid;
100
+ }
101
+ /** Join the room and collect the agent's opening turn (empty when the agent waits for the caller). */
102
+ static async open(token, callSid, opts = {}) {
103
+ const rtc = exports.deps.rtc();
104
+ const s = new LiveSession(callSid);
105
+ const room = new rtc.Room();
106
+ s.room = room;
107
+ const touch = () => { s.lastActivity = Date.now(); };
108
+ const push = (e) => { const ev = { ...e, t: Date.now() }; s.events.push(ev); touch(); return ev; };
109
+ const readAttrs = (attrs) => {
110
+ if (!attrs)
111
+ return;
112
+ if ('lk.agent.state' in attrs) {
113
+ s.agentState = attrs['lk.agent.state'];
114
+ s.sawState = true;
115
+ touch();
116
+ }
117
+ if (attrs.current_capability)
118
+ push({ type: 'capability', capability: attrs.current_capability });
119
+ };
120
+ // CK_TEST_DEBUG=<file> appends every raw room event, for when the worker's protocol drifts.
121
+ const debug = (kind, data) => {
122
+ if (process.env.CK_TEST_DEBUG)
123
+ (0, fs_1.appendFileSync)(process.env.CK_TEST_DEBUG, `${new Date().toISOString()} ${kind} ${JSON.stringify(data)}\n`);
124
+ };
125
+ room.registerTextStreamHandler('lk.transcription', async (reader, from) => {
126
+ s.openStreams++;
127
+ touch();
128
+ try {
129
+ const text = String(await reader.readAll()).trim();
130
+ debug('transcription', { from: from.identity, attributes: reader.info?.attributes, text });
131
+ if (!text || from.identity === exports.TEST_IDENTITY || text === s.lastSent)
132
+ return;
133
+ const seg = reader.info?.attributes?.['lk.segment_id'];
134
+ const prior = seg && s.segments.get(seg);
135
+ if (prior) {
136
+ prior.text = text;
137
+ touch();
138
+ return;
139
+ }
140
+ const ev = push({ type: 'agent', text });
141
+ if (seg)
142
+ s.segments.set(seg, ev);
143
+ }
144
+ finally {
145
+ s.openStreams--;
146
+ }
147
+ });
148
+ room.on('dataReceived', (payload, p, _k, topic) => {
149
+ debug('data', { from: p?.identity, topic, payload: new TextDecoder().decode(payload).slice(0, 2000) });
150
+ if (topic !== 'lk.history')
151
+ return;
152
+ try {
153
+ const data = JSON.parse(new TextDecoder().decode(payload));
154
+ if (data.type === 'message') {
155
+ // Duplicates the transcript text; only the count matters (see the header).
156
+ s.sawHistory = true;
157
+ if (data.role === 'assistant')
158
+ s.agentMessages++;
159
+ touch();
160
+ }
161
+ else if (data.type === 'function') {
162
+ let args = data.arguments;
163
+ try {
164
+ if (typeof args === 'string')
165
+ args = JSON.parse(args || '{}');
166
+ }
167
+ catch { /* keep the string */ }
168
+ push({ type: 'function', name: data.name, arguments: args });
169
+ }
170
+ else {
171
+ push({ type: 'history', data });
172
+ }
173
+ }
174
+ catch { /* not JSON */ }
175
+ });
176
+ room.on('participantConnected', (p) => { debug('joined', { identity: p.identity, kind: p.kind, attributes: p.attributes }); readAttrs(p.attributes); });
177
+ room.on('participantAttributesChanged', (changed, p) => {
178
+ debug('attributes', { from: p?.identity, changed });
179
+ if (p?.identity !== exports.TEST_IDENTITY)
180
+ readAttrs(changed);
181
+ });
182
+ const end = () => { if (!s.ended) {
183
+ s.ended = true;
184
+ push({ type: 'ended' });
185
+ } };
186
+ room.on('participantDisconnected', (p) => { if (p?.identity !== exports.TEST_IDENTITY)
187
+ end(); });
188
+ room.on('disconnected', (reason) => { debug('disconnected', { reason }); end(); });
189
+ await room.connect(exports.LIVEKIT_URL, token, { autoSubscribe: false });
190
+ for (const p of room.remoteParticipants?.values?.() || [])
191
+ readAttrs(p.attributes);
192
+ const greeting = await s.waitTurn(opts.greetingTimeoutMs ?? 20_000, 0);
193
+ return { session: s, greeting };
194
+ }
195
+ /**
196
+ * Wait until the agent has finished its turn: it spoke or called a function since `from` (and,
197
+ * once the worker is known to send them, a finished-message item arrived on `lk.history`), no transcript
198
+ * stream is still open, the agent reports `listening` (or never reports a state), and nothing
199
+ * new has arrived for a short settle window. `lk.agent.state` makes the window short; without
200
+ * it a longer quiet period has to stand in.
201
+ */
202
+ async waitTurn(timeoutMs, from = this.events.length, messagesBefore = this.agentMessages) {
203
+ const deadline = Date.now() + timeoutMs;
204
+ while (Date.now() < deadline && !this.ended) {
205
+ const spoke = this.events.slice(from).some((e) => e.type === 'agent' || e.type === 'function');
206
+ const got = spoke && (!this.sawHistory || this.agentMessages > messagesBefore);
207
+ const idle = this.openStreams === 0 && (!this.sawState || this.agentState === 'listening');
208
+ const quiet = Date.now() - this.lastActivity >= (this.sawState ? 700 : 2500);
209
+ if (got && idle && quiet)
210
+ break;
211
+ await sleep(100);
212
+ }
213
+ return this.events.slice(from);
214
+ }
215
+ async say(text, timeoutMs = 60_000) {
216
+ if (this.ended)
217
+ return [{ type: 'ended', t: Date.now() }];
218
+ const from = this.events.length;
219
+ const messagesBefore = this.agentMessages;
220
+ this.events.push({ type: 'user', text, t: Date.now() });
221
+ this.lastSent = text.trim();
222
+ this.lastActivity = Date.now();
223
+ await this.room.localParticipant.sendText(text, { topic: 'lk.chat' });
224
+ // Give the agent a moment to leave `listening`, so a still-idle state is not read as done.
225
+ if (this.sawState) {
226
+ const until = Date.now() + 3000;
227
+ while (this.agentState === 'listening' && Date.now() < until && !this.ended)
228
+ await sleep(50);
229
+ }
230
+ return this.waitTurn(timeoutMs, from + 1, messagesBefore);
231
+ }
232
+ async close() {
233
+ try {
234
+ await this.room?.disconnect();
235
+ }
236
+ catch { /* already gone */ }
237
+ try {
238
+ exports.deps.rtc().dispose?.();
239
+ }
240
+ catch { /* best effort */ }
241
+ }
242
+ }
243
+ exports.LiveSession = LiveSession;
244
+ /** One line per event, numbered by turn, for people and for the judge. */
245
+ function formatTranscript(events) {
246
+ const lines = [];
247
+ let turn = 0;
248
+ for (const e of events) {
249
+ if (e.type === 'agent')
250
+ lines.push(`T${++turn} agent: ${e.text}`);
251
+ else if (e.type === 'user')
252
+ lines.push(`T${++turn} caller: ${e.text}`);
253
+ else if (e.type === 'function')
254
+ lines.push(` [function ${e.name} ${JSON.stringify(e.arguments ?? {})}]`);
255
+ else if (e.type === 'capability')
256
+ lines.push(` [node: ${e.capability}]`);
257
+ else if (e.type === 'ended')
258
+ lines.push(' [call ended]');
259
+ }
260
+ return lines.join('\n');
261
+ }
262
+ // --- LLM caller and judge -------------------------------------------------------------------
263
+ /** Every provider here speaks the OpenAI chat-completions shape, so one client covers them all. */
264
+ exports.LLM_PRESETS = {
265
+ openai: { url: 'https://api.openai.com/v1', env: 'OPENAI_API_KEY', model: 'gpt-4.1-mini' },
266
+ anthropic: { url: 'https://api.anthropic.com/v1', env: 'ANTHROPIC_API_KEY', model: 'claude-haiku-5-5' },
267
+ gemini: { url: 'https://generativelanguage.googleapis.com/v1beta/openai', env: 'GEMINI_API_KEY', model: 'gemini-2.5-flash' },
268
+ deepseek: { url: 'https://api.deepseek.com/v1', env: 'DEEPSEEK_API_KEY', model: 'deepseek-chat' },
269
+ groq: { url: 'https://api.groq.com/openai/v1', env: 'GROQ_API_KEY', model: 'llama-3.3-70b-versatile' },
270
+ openrouter: { url: 'https://openrouter.ai/api/v1', env: 'OPENROUTER_API_KEY', model: 'openai/gpt-4.1-mini' },
271
+ ollama: { url: 'http://localhost:11434/v1', model: 'llama3.1' },
272
+ };
273
+ /**
274
+ * Pick the provider: --llm, else --llm-url (custom, key in CKU_LLM_API_KEY), else the first
275
+ * preset whose key is in the environment. Keys only come from the environment, never a flag,
276
+ * so they stay out of shell history and process listings.
277
+ */
278
+ function resolveLlm(opts) {
279
+ const names = Object.keys(exports.LLM_PRESETS);
280
+ let name = opts.llm;
281
+ if (!name && opts.llmUrl)
282
+ name = 'custom';
283
+ if (!name)
284
+ name = names.find((n) => exports.LLM_PRESETS[n].env && process.env[exports.LLM_PRESETS[n].env]);
285
+ if (!name) {
286
+ throw new Error(`No LLM for the simulated caller. Set one of ${names.filter((n) => exports.LLM_PRESETS[n].env).map((n) => exports.LLM_PRESETS[n].env).join(', ')}, `
287
+ + 'or pass --llm ollama, or --llm-url <OpenAI-compatible base URL> with the key in CKU_LLM_API_KEY. '
288
+ + 'To play the caller yourself instead, use: cku test start <agentId>');
289
+ }
290
+ if (name === 'custom') {
291
+ if (!opts.llmUrl)
292
+ throw new Error('--llm custom needs --llm-url <OpenAI-compatible base URL>.');
293
+ if (!opts.llmModel)
294
+ throw new Error('--llm-url needs --llm-model <model id>.');
295
+ return { name, url: opts.llmUrl.replace(/\/+$/, ''), key: process.env.CKU_LLM_API_KEY, model: opts.llmModel };
296
+ }
297
+ const p = exports.LLM_PRESETS[name];
298
+ if (!p)
299
+ throw new Error(`Unknown --llm "${name}". Use one of: ${names.join(', ')}, custom.`);
300
+ const key = process.env.CKU_LLM_API_KEY || (p.env ? process.env[p.env] : undefined);
301
+ if (p.env && !key)
302
+ throw new Error(`--llm ${name} needs ${p.env} (or CKU_LLM_API_KEY) set in the environment.`);
303
+ return { name, url: (opts.llmUrl || p.url).replace(/\/+$/, ''), key, model: opts.llmModel || p.model };
304
+ }
305
+ async function chatComplete(cfg, messages, temperature = 0.7) {
306
+ const res = await fetch(`${cfg.url}/chat/completions`, {
307
+ method: 'POST',
308
+ headers: { 'Content-Type': 'application/json', ...(cfg.key ? { Authorization: `Bearer ${cfg.key}` } : {}) },
309
+ body: JSON.stringify({ model: cfg.model, messages, temperature }),
310
+ signal: AbortSignal.timeout(120_000),
311
+ });
312
+ const body = await res.text();
313
+ if (!res.ok)
314
+ throw new Error(`${cfg.name} (${cfg.model}) returned HTTP ${res.status}: ${body.slice(0, 300)}`);
315
+ const content = JSON.parse(body)?.choices?.[0]?.message?.content;
316
+ return Array.isArray(content) ? content.map((c) => c.text ?? '').join('') : String(content ?? '');
317
+ }
318
+ exports.END_MARK = '[END]';
319
+ function callerSystemPrompt(s) {
320
+ return [
321
+ 'You are role-playing a person on a phone call with a voice agent. Stay in character the whole call.',
322
+ s.persona ? `Who you are: ${s.persona}` : '',
323
+ `What happens in this call: ${s.prompt}`,
324
+ 'Rules:',
325
+ '- Reply with only the words you say out loud: one short spoken turn, usually one or two sentences. No stage directions, no quotes, no labels.',
326
+ '- Speak the way this person would, in their language and register (Hindi, Hinglish, English, ...).',
327
+ '- Never say you are a test, a simulation or an AI.',
328
+ `- When the call is over for you (goal done, you give up, or the agent said goodbye), end your reply with ${exports.END_MARK}. Reply with only ${exports.END_MARK} if there is nothing left to say.`,
329
+ ].filter(Boolean).join('\n');
330
+ }
331
+ /** The caller's next line, and whether it hangs up after it. */
332
+ function parseCallerLine(raw) {
333
+ const end = raw.includes(exports.END_MARK);
334
+ const text = raw.replace(exports.END_MARK, '').replace(/^\s*(caller|me|you)\s*:\s*/i, '').trim().replace(/^"(.*)"$/s, '$1');
335
+ return { text, end };
336
+ }
337
+ function judgeMessages(criteria, events) {
338
+ return [
339
+ {
340
+ role: 'system',
341
+ content: 'You grade a test call between a simulated caller and a voice agent. Judge only from the transcript. '
342
+ + 'Split the success criteria into separate checks. A check passes only if the transcript shows it; anything the '
343
+ + 'criteria forbid fails. Cite the turn (T3) or function line that decides each check. Answer with JSON only: '
344
+ + '{"pass": boolean, "criteria": [{"criterion": string, "pass": boolean, "evidence": string, "reason": string}], "summary": string}. '
345
+ + '"pass" is true only if every check passes.',
346
+ },
347
+ { role: 'user', content: `Success criteria:\n${criteria}\n\nTranscript:\n${formatTranscript(events)}` },
348
+ ];
349
+ }
350
+ /** The judge's JSON, tolerating a code fence or prose around it. A reply with no JSON is INVALID, not FAIL. */
351
+ function parseVerdict(raw) {
352
+ const m = raw.match(/\{[\s\S]*\}/);
353
+ try {
354
+ const v = JSON.parse(m ? m[0] : raw);
355
+ const criteria = Array.isArray(v.criteria) ? v.criteria : [];
356
+ return { pass: v.pass === true && criteria.every((c) => c.pass !== false), criteria, summary: String(v.summary ?? '') };
357
+ }
358
+ catch {
359
+ return { pass: false, criteria: [], summary: `Judge did not return JSON: ${raw.slice(0, 200)}`, invalid: true };
360
+ }
361
+ }
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.checkVoiceConfigs = checkVoiceConfigs;
4
+ exports.savedVoices = savedVoices;
5
+ /** Validate TTS provider/model pairs against the account's live voice catalogue. */
6
+ const slug = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
7
+ async function checkVoiceConfigs(api, entries, saved) {
8
+ const todo = Object.entries(entries).filter(([, config]) => !!config && typeof config === 'object' && !Array.isArray(config) && typeof config.voice_provider === 'string');
9
+ if (!todo.length)
10
+ return;
11
+ let providers;
12
+ try {
13
+ providers = (await api.getVoiceCatalog()).providers || [];
14
+ }
15
+ catch (error) {
16
+ console.error(`! Could not check voices against the live catalogue (${error.message}); the backend will still validate.`);
17
+ return;
18
+ }
19
+ let savedPairs;
20
+ const alreadySaved = async (config) => {
21
+ if (!saved)
22
+ return false;
23
+ if (!savedPairs) {
24
+ try {
25
+ savedPairs = new Set((await saved()).filter((voice) => typeof voice?.voice_provider === 'string')
26
+ .map((voice) => `${slug(voice.voice_provider)}|${voice.voice_model ?? ''}`));
27
+ }
28
+ catch {
29
+ savedPairs = new Set();
30
+ }
31
+ }
32
+ return savedPairs.has(`${slug(String(config.voice_provider))}|${config.voice_model ?? ''}`);
33
+ };
34
+ for (const [label, raw] of todo) {
35
+ const config = raw;
36
+ const name = String(config.voice_provider);
37
+ const provider = providers.find((candidate) => slug(candidate.provider) === slug(name));
38
+ if (!provider) {
39
+ if (await alreadySaved(config))
40
+ continue;
41
+ throw new Error(`Unknown voice_provider "${name}" in ${label}\n allowed (live): ${providers.map((candidate) => candidate.provider).join(', ')}`);
42
+ }
43
+ if (name !== provider.provider)
44
+ config.voice_provider = provider.provider;
45
+ const models = provider.models || [];
46
+ if (typeof config.voice_model === 'string' && config.voice_model && models.length && !models.includes(config.voice_model)) {
47
+ if (await alreadySaved(config))
48
+ continue;
49
+ throw new Error(`"${config.voice_model}" is not a ${provider.provider} voice_model (in ${label})\n allowed (live): ${models.join(', ')}`);
50
+ }
51
+ }
52
+ }
53
+ function savedVoices(version) {
54
+ return [version?.voice_configuration, version?.secondary_voice_configuration, ...(version?.agentMemory?.voice_rotation_config?.voices || [])].filter(Boolean);
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "callkaro-cli-unofficial",
3
- "version": "0.9.4",
3
+ "version": "0.10.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Unofficial fork of the CallKaro CLI — manage your voice AI agents from the terminal",
6
6
  "bin": {
@@ -48,5 +48,8 @@
48
48
  "cli",
49
49
  "agents",
50
50
  "telephony"
51
- ]
51
+ ],
52
+ "optionalDependencies": {
53
+ "@livekit/rtc-node": "^1.2.2"
54
+ }
52
55
  }