samoagent 0.3.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/cli.js ADDED
@@ -0,0 +1,1580 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/config.ts
5
+ import { homedir } from "os";
6
+ import { join, dirname } from "path";
7
+ import { fileURLToPath } from "url";
8
+ var RECALL_BASE = "https://us-east-1.recall.ai/api/v1";
9
+ var AVATAR_URL = "https://nikolays.github.io/samoagent/avatar.html";
10
+
11
+ class ExitError extends Error {
12
+ code;
13
+ constructor(code) {
14
+ super(`ExitError(${code})`);
15
+ this.code = code;
16
+ this.name = "ExitError";
17
+ }
18
+ }
19
+ function repoRoot() {
20
+ return dirname(dirname(fileURLToPath(import.meta.url)));
21
+ }
22
+ function stateFile() {
23
+ return process.env.SAMOAGENT_STATE_FILE ?? join(homedir(), ".samoagent", "state.json");
24
+ }
25
+ function dictDir() {
26
+ return process.env.SAMOAGENT_DICT_DIR ?? join(repoRoot(), "dictionaries");
27
+ }
28
+ function samoagentDir() {
29
+ const base = process.env.SAMOAGENT_HOME ?? homedir();
30
+ return join(base, ".samoagent");
31
+ }
32
+ function defaultTranscriptFile() {
33
+ return join(samoagentDir(), "transcript.txt");
34
+ }
35
+ function apiKey() {
36
+ const k = process.env.RECALL_API_KEY ?? "";
37
+ if (!k) {
38
+ process.stderr.write(`Error: RECALL_API_KEY not set
39
+ `);
40
+ throw new ExitError(1);
41
+ }
42
+ return k;
43
+ }
44
+ function headers() {
45
+ return {
46
+ Authorization: `Token ${apiKey()}`,
47
+ "Content-Type": "application/json"
48
+ };
49
+ }
50
+
51
+ // src/commands/join.ts
52
+ import { writeFileSync as writeFileSync4 } from "fs";
53
+ import { randomUUID } from "crypto";
54
+ import { fileURLToPath as fileURLToPath2 } from "url";
55
+
56
+ // src/transcript.ts
57
+ import {
58
+ existsSync as existsSync2,
59
+ readFileSync as readFileSync2,
60
+ writeFileSync as writeFileSync2,
61
+ mkdirSync as mkdirSync2,
62
+ openSync,
63
+ readSync,
64
+ closeSync
65
+ } from "fs";
66
+ import { join as join2 } from "path";
67
+ import { homedir as homedir2 } from "os";
68
+ import { StringDecoder } from "string_decoder";
69
+
70
+ // src/state.ts
71
+ import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
72
+ import { dirname as dirname2 } from "path";
73
+ function loadState() {
74
+ const f = stateFile();
75
+ if (existsSync(f)) {
76
+ return JSON.parse(readFileSync(f, "utf-8"));
77
+ }
78
+ return {};
79
+ }
80
+ function saveState(state) {
81
+ const f = stateFile();
82
+ const dir = dirname2(f);
83
+ const dirExisted = existsSync(dir);
84
+ mkdirSync(dir, { recursive: true, mode: 448 });
85
+ if (!dirExisted || process.env.SAMOAGENT_STATE_FILE === undefined) {
86
+ chmodSync(dir, 448);
87
+ }
88
+ writeFileSync(f, JSON.stringify(state, null, 2), { mode: 384 });
89
+ chmodSync(f, 384);
90
+ }
91
+ function botIdFromArgsOrState(argBotId) {
92
+ if (argBotId) {
93
+ return argBotId;
94
+ }
95
+ const state = loadState();
96
+ const bid = state.bot_id;
97
+ if (!bid || typeof bid !== "string") {
98
+ process.stderr.write(`Error: no active bot. Pass BOT_ID or run 'samoagent join' first.
99
+ `);
100
+ throw new ExitError(1);
101
+ }
102
+ return bid;
103
+ }
104
+
105
+ // src/transcript.ts
106
+ var SENTINEL_RE = /^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] SAMOAGENT_CALL_ENDED$/;
107
+ function expanduser(p) {
108
+ if (p === "~")
109
+ return homedir2();
110
+ if (p.startsWith("~/"))
111
+ return join2(homedir2(), p.slice(2));
112
+ return p;
113
+ }
114
+ function resolveTranscriptFile(transcriptDir) {
115
+ let d;
116
+ if (transcriptDir) {
117
+ d = expanduser(transcriptDir);
118
+ } else {
119
+ d = samoagentDir();
120
+ }
121
+ mkdirSync2(d, { recursive: true });
122
+ return join2(d, "transcript.txt");
123
+ }
124
+ function sanitizeTranscriptField(value) {
125
+ return value.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
126
+ }
127
+ function formatTranscriptLine(payload) {
128
+ const p = payload ?? {};
129
+ if (p.event !== "transcript.data") {
130
+ return null;
131
+ }
132
+ const inner = p.data?.data ?? {};
133
+ const words = inner.words ?? [];
134
+ if (!words.length) {
135
+ return null;
136
+ }
137
+ const text = sanitizeTranscriptField(words.map((w) => w.text ?? "").join(" "));
138
+ const speaker = sanitizeTranscriptField(inner.participant?.name ?? "") || "?";
139
+ const absolute = words[0]?.start_timestamp?.absolute ?? "";
140
+ const ts = absolute.slice(0, 19).replace("T", " ");
141
+ return `[${ts}] ${speaker}: ${text}`;
142
+ }
143
+ function transcriptPathFromState() {
144
+ const state = loadState();
145
+ const tf = state.transcript_file;
146
+ if (typeof tf === "string" && tf) {
147
+ return tf;
148
+ }
149
+ return defaultTranscriptFile();
150
+ }
151
+ function printLocalTranscript() {
152
+ const tf = transcriptPathFromState();
153
+ if (existsSync2(tf)) {
154
+ const lines = readFileSync2(tf, "utf-8").split(/\r?\n/).filter((l) => l.trim() && !SENTINEL_RE.test(l));
155
+ if (lines.length) {
156
+ const tail = lines.slice(-20);
157
+ const base = tf.split("/").pop() ?? tf;
158
+ process.stdout.write(`
159
+ --- last ${Math.min(20, lines.length)} lines from ${base} ---
160
+ `);
161
+ for (const line of tail) {
162
+ process.stdout.write(line + `
163
+ `);
164
+ }
165
+ } else {
166
+ process.stdout.write(`${tf} is empty -- call may not have started yet.
167
+ `);
168
+ }
169
+ } else {
170
+ process.stdout.write(`Transcript not found at ${tf}
171
+ `);
172
+ }
173
+ }
174
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
175
+ async function watch(opts = {}) {
176
+ const pollMs = opts.pollMs ?? 100;
177
+ const stateGoneCheckEvery = opts.stateGoneCheckEvery ?? 20;
178
+ const appearWaitMs = opts.appearWaitMs ?? 30000;
179
+ const tf = transcriptPathFromState();
180
+ let waited = 0;
181
+ while (!existsSync2(tf)) {
182
+ mkdirSync2(join2(tf, ".."), { recursive: true });
183
+ await sleep(500);
184
+ waited += 500;
185
+ if (waited >= appearWaitMs) {
186
+ writeFileSync2(tf, "");
187
+ break;
188
+ }
189
+ }
190
+ if (!existsSync2(stateFile())) {
191
+ process.stderr.write(`No active session. Run 'samoagent join' first.
192
+ `);
193
+ return;
194
+ }
195
+ for (const existing of readFileSync2(tf, "utf-8").split(/\r?\n/)) {
196
+ if (SENTINEL_RE.test(existing.replace(/\n$/, ""))) {
197
+ return;
198
+ }
199
+ }
200
+ const fd = openSync(tf, "r");
201
+ try {
202
+ let pos = Bun.file(tf).size;
203
+ let pollCounter = 0;
204
+ let buffer = "";
205
+ const chunk = Buffer.alloc(64 * 1024);
206
+ let decoder = new StringDecoder("utf-8");
207
+ while (true) {
208
+ const size = Bun.file(tf).size;
209
+ if (size < pos) {
210
+ pos = 0;
211
+ buffer = "";
212
+ decoder = new StringDecoder("utf-8");
213
+ }
214
+ if (size > pos) {
215
+ const toRead = size - pos;
216
+ let remaining = toRead;
217
+ let offset = pos;
218
+ let data = "";
219
+ while (remaining > 0) {
220
+ const n = readSync(fd, chunk, 0, Math.min(chunk.length, remaining), offset);
221
+ if (n <= 0)
222
+ break;
223
+ data += decoder.write(chunk.subarray(0, n));
224
+ offset += n;
225
+ remaining -= n;
226
+ }
227
+ pos = offset;
228
+ buffer += data;
229
+ let idx;
230
+ while ((idx = buffer.indexOf(`
231
+ `)) !== -1) {
232
+ const line = buffer.slice(0, idx);
233
+ buffer = buffer.slice(idx + 1);
234
+ if (SENTINEL_RE.test(line.replace(/\r$/, ""))) {
235
+ return;
236
+ }
237
+ process.stdout.write(line + `
238
+ `);
239
+ }
240
+ } else {
241
+ pollCounter += 1;
242
+ await sleep(pollMs);
243
+ if (pollCounter % stateGoneCheckEvery === 0 && !existsSync2(stateFile())) {
244
+ return;
245
+ }
246
+ }
247
+ }
248
+ } finally {
249
+ closeSync(fd);
250
+ }
251
+ }
252
+
253
+ // src/frameStore.ts
254
+ import {
255
+ chmodSync as chmodSync2,
256
+ copyFileSync,
257
+ existsSync as existsSync3,
258
+ mkdirSync as mkdirSync3,
259
+ readFileSync as readFileSync3,
260
+ writeFileSync as writeFileSync3
261
+ } from "fs";
262
+ import { dirname as dirname3, extname, join as join3, resolve } from "path";
263
+ import { homedir as homedir3 } from "os";
264
+ import { Buffer as Buffer2 } from "buffer";
265
+ function expandUser(path) {
266
+ if (path === "~")
267
+ return homedir3();
268
+ if (path.startsWith("~/"))
269
+ return join3(homedir3(), path.slice(2));
270
+ return path;
271
+ }
272
+ function resolveVideoFrameDir(frameDir, create = true) {
273
+ const dir = frameDir ? expandUser(frameDir) : join3(samoagentDir(), "frames");
274
+ if (create) {
275
+ const dirExisted = existsSync3(dir);
276
+ mkdirSync3(dir, { recursive: true, mode: 448 });
277
+ if (!dirExisted || !frameDir) {
278
+ chmodSync2(dir, 448);
279
+ }
280
+ }
281
+ return dir;
282
+ }
283
+ function resolveVideoFrameFile(frameDir, create = true) {
284
+ return join3(resolveVideoFrameDir(frameDir, create), "latest.png");
285
+ }
286
+ function resolveFrameOutput(out, state) {
287
+ if (out)
288
+ return expandUser(out);
289
+ const stateFile2 = state.video_frame_file;
290
+ if (typeof stateFile2 === "string" && stateFile2)
291
+ return expandUser(stateFile2);
292
+ return resolveVideoFrameFile();
293
+ }
294
+ function safeFilenamePart(value, fallback = "unknown") {
295
+ const raw = String(value || fallback);
296
+ const safe = raw.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-._]+|[-._]+$/g, "");
297
+ return safe || fallback;
298
+ }
299
+ function frameMetadataPath(framePath) {
300
+ const ext = extname(framePath);
301
+ return ext ? framePath.slice(0, -ext.length) + ".json" : framePath + ".json";
302
+ }
303
+ function frameTimestampForFilename(timestamp) {
304
+ const absolute = timestamp?.absolute;
305
+ const dt = absolute ? new Date(absolute) : new Date;
306
+ const valid = Number.isNaN(dt.getTime()) ? new Date : dt;
307
+ return valid.toISOString().replace(/[-:]/g, "").replace(/\.(\d{3})Z$/, ".$1000Z");
308
+ }
309
+ function archivedFramePath(frameDir, metadata) {
310
+ const participant = metadata.participant ?? {};
311
+ const callPart = safeFilenamePart(metadata.call_id, "no-call");
312
+ const timestampPart = frameTimestampForFilename(metadata.timestamp);
313
+ const sourceType = safeFilenamePart(metadata.type, "frame");
314
+ const participantId = safeFilenamePart(participant.id, "unknown");
315
+ return join3(frameDir, `${callPart}_${timestampPart}_${sourceType}_${participantId}.png`);
316
+ }
317
+ function writeFrameFiles(out, raw, metadata) {
318
+ const dir = dirname3(out);
319
+ const dirExisted = existsSync3(dir);
320
+ mkdirSync3(dir, { recursive: true, mode: 448 });
321
+ if (!dirExisted) {
322
+ chmodSync2(dir, 448);
323
+ }
324
+ writeFileSync3(out, raw, { mode: 384 });
325
+ chmodSync2(out, 384);
326
+ if (metadata !== undefined) {
327
+ const metadataFile = frameMetadataPath(out);
328
+ writeFileSync3(metadataFile, JSON.stringify(metadata, null, 2), { mode: 384 });
329
+ chmodSync2(metadataFile, 384);
330
+ }
331
+ }
332
+ function archiveFrameBytes(frameDir, raw, metadata) {
333
+ const archiveFile = archivedFramePath(frameDir, metadata);
334
+ const archived = {
335
+ ...metadata,
336
+ archive_file: archiveFile,
337
+ archived_at: new Date().toISOString()
338
+ };
339
+ writeFrameFiles(archiveFile, raw, archived);
340
+ return archiveFile;
341
+ }
342
+ function archiveExistingFrame(latestFile) {
343
+ const metadataFile = frameMetadataPath(latestFile);
344
+ const metadata = existsSync3(metadataFile) ? JSON.parse(readFileSync3(metadataFile, "utf-8")) : {};
345
+ const archiveFile = archivedFramePath(dirname3(latestFile), metadata);
346
+ const archiveDir = dirname3(archiveFile);
347
+ const dirExisted = existsSync3(archiveDir);
348
+ mkdirSync3(archiveDir, { recursive: true, mode: 448 });
349
+ if (!dirExisted) {
350
+ chmodSync2(archiveDir, 448);
351
+ }
352
+ copyFileSync(latestFile, archiveFile);
353
+ chmodSync2(archiveFile, 384);
354
+ writeFileSync3(frameMetadataPath(archiveFile), JSON.stringify({
355
+ ...metadata,
356
+ archive_file: archiveFile,
357
+ archived_at: new Date().toISOString()
358
+ }, null, 2), { mode: 384 });
359
+ chmodSync2(frameMetadataPath(archiveFile), 384);
360
+ return archiveFile;
361
+ }
362
+ function decodeVideoSeparatePng(payload, callId) {
363
+ const p = payload ?? {};
364
+ if (p.event !== "video_separate_png.data")
365
+ return null;
366
+ const inner = p.data?.data ?? {};
367
+ if (!inner.buffer)
368
+ return null;
369
+ const raw = new Uint8Array(Buffer2.from(inner.buffer, "base64"));
370
+ const participant = inner.participant ?? {};
371
+ return {
372
+ raw,
373
+ metadata: {
374
+ event: p.event,
375
+ call_id: callId ?? null,
376
+ type: inner.type ?? null,
377
+ participant: {
378
+ id: participant.id ?? null,
379
+ name: participant.name ?? null,
380
+ is_host: participant.is_host ?? null
381
+ },
382
+ timestamp: inner.timestamp ?? null,
383
+ updated_at: new Date().toISOString()
384
+ }
385
+ };
386
+ }
387
+
388
+ // src/dict.ts
389
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
390
+ import { join as join4 } from "path";
391
+ function loadDict(name) {
392
+ if (!name || name.toLowerCase() === "none") {
393
+ return [];
394
+ }
395
+ const path = join4(dictDir(), `${name}.txt`);
396
+ if (!existsSync4(path)) {
397
+ process.stdout.write(`Warning: dictionary '${name}' not found at ${path}, continuing without it.
398
+ `);
399
+ return [];
400
+ }
401
+ const terms = readFileSync4(path, "utf-8").split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
402
+ return terms.slice(0, 100);
403
+ }
404
+
405
+ // src/botName.ts
406
+ function botName(agentName) {
407
+ let base;
408
+ if (agentName) {
409
+ base = `${agentName} \uD83D\uDD34 (samoagent)`;
410
+ } else {
411
+ base = "samoagent \uD83D\uDD34";
412
+ }
413
+ return [...base].slice(0, 100).join("");
414
+ }
415
+
416
+ // src/recall.ts
417
+ function makeRecallClient(fetchFn = fetch) {
418
+ return {
419
+ async leaveCall(botId) {
420
+ return fetchFn(`${RECALL_BASE}/bot/${botId}/leave_call/`, {
421
+ method: "POST",
422
+ headers: headers(),
423
+ signal: AbortSignal.timeout(1e4)
424
+ });
425
+ },
426
+ async getBot(botId) {
427
+ const r = await fetchFn(`${RECALL_BASE}/bot/${botId}/`, {
428
+ method: "GET",
429
+ headers: headers(),
430
+ signal: AbortSignal.timeout(1e4)
431
+ });
432
+ if (!r.ok) {
433
+ const body = await r.text().catch(() => "");
434
+ throw new Error(`get bot failed: ${r.status} ${body}`);
435
+ }
436
+ try {
437
+ return await r.json();
438
+ } catch {
439
+ throw new Error("get bot failed: invalid JSON response");
440
+ }
441
+ },
442
+ async sendChat(botId, message) {
443
+ return fetchFn(`${RECALL_BASE}/bot/${botId}/send_chat_message/`, {
444
+ method: "POST",
445
+ headers: headers(),
446
+ body: JSON.stringify({ message }),
447
+ signal: AbortSignal.timeout(1e4)
448
+ });
449
+ },
450
+ async screenshot(botId) {
451
+ return fetchFn(`${RECALL_BASE}/bot/${botId}/screenshot/`, {
452
+ method: "GET",
453
+ headers: headers(),
454
+ signal: AbortSignal.timeout(15000),
455
+ redirect: "follow"
456
+ });
457
+ },
458
+ async createBot(payload) {
459
+ const r = await fetchFn(`${RECALL_BASE}/bot/`, {
460
+ method: "POST",
461
+ headers: headers(),
462
+ body: JSON.stringify(payload),
463
+ signal: AbortSignal.timeout(30000)
464
+ });
465
+ if (!r.ok) {
466
+ const body = await r.text().catch(() => "");
467
+ throw new Error(`recall.ai bot creation failed: ${r.status} ${body}`);
468
+ }
469
+ return r.json();
470
+ }
471
+ };
472
+ }
473
+
474
+ // src/rtmp.ts
475
+ import {
476
+ existsSync as existsSync5,
477
+ mkdirSync as mkdirSync4,
478
+ copyFileSync as copyFileSync2,
479
+ chmodSync as chmodSync3
480
+ } from "fs";
481
+ import { join as join5 } from "path";
482
+ import { homedir as homedir4 } from "os";
483
+ function rtmpStreamPath(rtmpUrl) {
484
+ const parsed = new URL(rtmpUrl);
485
+ return parsed.pathname.replace(/^\/+/, "");
486
+ }
487
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
488
+ async function ngrokApiPort(fetchFn = fetch) {
489
+ for (const p of [4040, 4041, 4042, 4043]) {
490
+ try {
491
+ const ctrl = AbortSignal.timeout(1000);
492
+ const r = await fetchFn(`http://localhost:${p}/api/tunnels`, {
493
+ signal: ctrl
494
+ });
495
+ await r.text().catch(() => {
496
+ return;
497
+ });
498
+ return p;
499
+ } catch {}
500
+ }
501
+ return 4040;
502
+ }
503
+ async function waitForNgrok(port, timeout = 15, fetchFn = fetch) {
504
+ const deadline = Date.now() + timeout * 1000;
505
+ while (Date.now() < deadline) {
506
+ try {
507
+ const apiPort = await ngrokApiPort(fetchFn);
508
+ const r = await fetchFn(`http://localhost:${apiPort}/api/tunnels`, {
509
+ signal: AbortSignal.timeout(2000)
510
+ });
511
+ const data = await r.json();
512
+ const tunnels = data.tunnels ?? [];
513
+ for (const t of tunnels) {
514
+ if ((t.config?.addr ?? "").includes(String(port))) {
515
+ return t.public_url;
516
+ }
517
+ }
518
+ if (tunnels.length) {
519
+ return tunnels[0].public_url;
520
+ }
521
+ } catch {}
522
+ await sleep2(1000);
523
+ }
524
+ return null;
525
+ }
526
+ async function startNgrokTcpTunnel(localPort, fetchFn = fetch) {
527
+ const apiPort = await ngrokApiPort(fetchFn);
528
+ const payload = JSON.stringify({
529
+ addr: String(localPort),
530
+ proto: "tcp",
531
+ name: "rtmp"
532
+ });
533
+ try {
534
+ const r = await fetchFn(`http://localhost:${apiPort}/api/tunnels`, {
535
+ method: "POST",
536
+ headers: { "Content-Type": "application/json" },
537
+ body: payload,
538
+ signal: AbortSignal.timeout(1e4)
539
+ });
540
+ if (!r.ok) {
541
+ const body = await r.text().catch(() => "");
542
+ if (body.includes("ERR_NGROK_8013") || body.includes("credit or debit card")) {
543
+ process.stderr.write("Error: ngrok TCP tunnels require a credit/debit card on file " + `(free plan \u2014 your card will NOT be charged).
544
+ ` + `Add a card at: https://dashboard.ngrok.com/settings#id-verification
545
+ ` + `Then retry with --rtmp.
546
+ `);
547
+ } else {
548
+ process.stderr.write(`Error starting ngrok TCP tunnel: ${body.slice(0, 500)}
549
+ `);
550
+ }
551
+ return null;
552
+ }
553
+ const data = await r.json();
554
+ return data.public_url ?? null;
555
+ } catch (e) {
556
+ process.stderr.write(`Error starting ngrok TCP tunnel: ${e}
557
+ `);
558
+ return null;
559
+ }
560
+ }
561
+ async function ensureMediamtx() {
562
+ const inPath = Bun.which("mediamtx");
563
+ if (inPath) {
564
+ return inPath;
565
+ }
566
+ const localBin = join5(homedir4(), ".samoagent", "bin", "mediamtx");
567
+ if (existsSync5(localBin)) {
568
+ return localBin;
569
+ }
570
+ const machine = process.arch;
571
+ const realArch = machine === "arm64" || machine === "aarch64" ? "arm64" : "amd64";
572
+ const plat = process.platform;
573
+ if (plat !== "darwin" && plat !== "linux") {
574
+ throw new Error(`Unsupported platform for mediamtx auto-download: ${plat}`);
575
+ }
576
+ const version = "v1.9.1";
577
+ const filename = `mediamtx_${version}_${plat}_${realArch}.tar.gz`;
578
+ const url = `https://github.com/bluenviron/mediamtx/releases/download/${version}/${filename}`;
579
+ process.stdout.write(`Downloading mediamtx ${version}...
580
+ `);
581
+ const tmpdir = join5(homedir4(), ".samoagent", "tmp-mediamtx");
582
+ mkdirSync4(tmpdir, { recursive: true });
583
+ const archivePath = join5(tmpdir, filename);
584
+ const resp = await fetch(url);
585
+ if (!resp.ok) {
586
+ throw new Error(`Failed to download mediamtx: ${resp.status}`);
587
+ }
588
+ await Bun.write(archivePath, resp);
589
+ await Bun.$`tar -xzf ${archivePath} -C ${tmpdir} mediamtx`.quiet();
590
+ const extracted = join5(tmpdir, "mediamtx");
591
+ if (!existsSync5(extracted)) {
592
+ throw new Error("mediamtx binary not found in downloaded archive");
593
+ }
594
+ mkdirSync4(join5(localBin, ".."), { recursive: true });
595
+ copyFileSync2(extracted, localBin);
596
+ chmodSync3(localBin, 493);
597
+ return localBin;
598
+ }
599
+ async function startMediamtx() {
600
+ const mediamtxBin = await ensureMediamtx();
601
+ const proc = Bun.spawn([mediamtxBin], {
602
+ stdout: "ignore",
603
+ stderr: "ignore"
604
+ });
605
+ await sleep2(1500);
606
+ if (proc.exitCode !== null) {
607
+ return null;
608
+ }
609
+ return proc;
610
+ }
611
+
612
+ // src/commands/join.ts
613
+ function defaultKill(pid, signal) {
614
+ try {
615
+ process.kill(pid, signal);
616
+ } catch {}
617
+ }
618
+ function defaultSpawn(cmd) {
619
+ const proc = Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" });
620
+ return {
621
+ get pid() {
622
+ return proc.pid;
623
+ },
624
+ kill() {
625
+ proc.kill();
626
+ }
627
+ };
628
+ }
629
+ async function cmdJoin(args, deps = {}) {
630
+ const recall = deps.recall ?? makeRecallClient();
631
+ const kill = deps.kill ?? defaultKill;
632
+ const spawn = deps.spawn ?? defaultSpawn;
633
+ const waitForNgrokFn = deps.waitForNgrok ?? waitForNgrok;
634
+ const startMediamtxFn = deps.startMediamtx ?? startMediamtx;
635
+ const startNgrokTcpTunnelFn = deps.startNgrokTcpTunnel ?? startNgrokTcpTunnel;
636
+ const transcriptFile = resolveTranscriptFile(args.transcript_dir);
637
+ writeFileSync4(transcriptFile, "");
638
+ const webhookToken = randomUUID();
639
+ const frameToken = randomUUID();
640
+ const keyterms = loadDict(args.dict);
641
+ const name = botName(args.name);
642
+ const port = args.port || 8080;
643
+ let rtmpUrl = args.rtmp_url ?? null;
644
+ const useRtmpAuto = args.rtmp ?? false;
645
+ const useWsVideo = args.ws_video ?? false;
646
+ const videoFrameDir = resolveVideoFrameDir(args.frame_dir, false);
647
+ const videoFrameFile = resolveVideoFrameFile(args.frame_dir, false);
648
+ const oldState = loadState();
649
+ for (const pidKey of ["server_pid", "ngrok_pid", "mediamtx_pid"]) {
650
+ const pid = oldState[pidKey];
651
+ if (typeof pid === "number" && pid) {
652
+ kill(pid, "SIGTERM");
653
+ }
654
+ }
655
+ const selfPath = fileURLToPath2(import.meta.url);
656
+ const cliPath = selfPath.replace(/commands\/join\.ts$/, "cli.ts");
657
+ const server = spawn([
658
+ process.execPath,
659
+ cliPath,
660
+ "_serve",
661
+ "--port",
662
+ String(port),
663
+ "--transcript-file",
664
+ transcriptFile,
665
+ "--webhook-token",
666
+ webhookToken,
667
+ "--call-id-file",
668
+ stateFile(),
669
+ "--frame-token",
670
+ frameToken
671
+ ]);
672
+ const started = new Set([server]);
673
+ let stateSaved = false;
674
+ const cleanupUnsaved = () => {
675
+ if (stateSaved)
676
+ return;
677
+ for (const proc of started) {
678
+ proc.kill();
679
+ }
680
+ started.clear();
681
+ };
682
+ const ngrok = spawn(["ngrok", "http", String(port), "--log=stdout"]);
683
+ started.add(ngrok);
684
+ try {
685
+ process.stdout.write(`Starting ngrok tunnel on port ${port}...
686
+ `);
687
+ let webhookUrl = await waitForNgrokFn(port);
688
+ if (!webhookUrl) {
689
+ process.stderr.write(`Error: could not get ngrok URL. Is ngrok installed and authenticated?
690
+ `);
691
+ cleanupUnsaved();
692
+ throw new ExitError(1);
693
+ }
694
+ webhookUrl = webhookUrl.replace(/\/+$/, "") + `/webhook?token=${encodeURIComponent(webhookToken)}`;
695
+ process.stdout.write(`Webhook: ${webhookUrl}
696
+ `);
697
+ let mediamtxAuto = null;
698
+ let rtmpViaNgrok = false;
699
+ if (useRtmpAuto && !rtmpUrl) {
700
+ process.stdout.write(`Starting mediamtx RTMP server for --rtmp...
701
+ `);
702
+ const mediamtxEarly = await startMediamtxFn();
703
+ if (!mediamtxEarly) {
704
+ process.stderr.write(`Warning: mediamtx failed to start \u2014 RTMP frame capture disabled.
705
+ `);
706
+ } else {
707
+ process.stdout.write(`Opening ngrok TCP tunnel to port 1935...
708
+ `);
709
+ started.add(mediamtxEarly);
710
+ const tcpPublic = await startNgrokTcpTunnelFn(1935);
711
+ if (tcpPublic === null) {
712
+ process.stderr.write(`Warning: ngrok TCP tunnel failed \u2014 RTMP frame capture disabled.
713
+ `);
714
+ mediamtxEarly.kill();
715
+ started.delete(mediamtxEarly);
716
+ } else {
717
+ rtmpUrl = tcpPublic.replace("tcp://", "rtmp://") + "/live/call";
718
+ process.stdout.write(`ngrok TCP tunnel: ${tcpPublic}
719
+ `);
720
+ process.stdout.write(`RTMP URL for recall.ai: ${rtmpUrl}
721
+ `);
722
+ mediamtxAuto = mediamtxEarly;
723
+ rtmpViaNgrok = true;
724
+ }
725
+ }
726
+ }
727
+ process.stdout.write(`Joining: ${args.url}
728
+ `);
729
+ const deepgramConfig = {
730
+ model: "nova-3",
731
+ language: "multi",
732
+ mip_opt_out: true
733
+ };
734
+ if (keyterms.length) {
735
+ deepgramConfig.keyterms = keyterms;
736
+ }
737
+ const realtimeEndpoints = [
738
+ {
739
+ type: "webhook",
740
+ url: webhookUrl,
741
+ events: ["transcript.data"]
742
+ }
743
+ ];
744
+ let mediamtxProc = null;
745
+ let rtmpLocalUrl = null;
746
+ let wsVideoUrl = null;
747
+ if (useWsVideo) {
748
+ wsVideoUrl = webhookUrl.replace(/\/webhook(?:\?.*)?$/, "/video-ws").replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
749
+ wsVideoUrl += `?token=${encodeURIComponent(frameToken)}`;
750
+ realtimeEndpoints.push({
751
+ type: "websocket",
752
+ url: wsVideoUrl,
753
+ events: ["video_separate_png.data"]
754
+ });
755
+ process.stdout.write(`WebSocket video: recall.ai \u2192 token-protected /video-ws \u2192 in-memory latest frame
756
+ `);
757
+ }
758
+ if (rtmpUrl) {
759
+ const rtmpHost = new URL(rtmpUrl).hostname || "";
760
+ const rtmpIsLocal = rtmpHost === "localhost" || rtmpHost === "127.0.0.1";
761
+ if (rtmpIsLocal) {
762
+ process.stdout.write(`Starting mediamtx RTMP server...
763
+ `);
764
+ mediamtxProc = await startMediamtxFn();
765
+ if (!mediamtxProc) {
766
+ process.stderr.write(`Warning: mediamtx failed to start \u2014 RTMP frame capture disabled.
767
+ `);
768
+ rtmpUrl = null;
769
+ } else {
770
+ started.add(mediamtxProc);
771
+ const streamPath = rtmpStreamPath(rtmpUrl);
772
+ rtmpLocalUrl = `rtmp://localhost:1935/${streamPath}`;
773
+ realtimeEndpoints.push({
774
+ type: "rtmp",
775
+ url: rtmpUrl,
776
+ events: ["video_mixed_flv.data"]
777
+ });
778
+ process.stdout.write(`RTMP: recall.ai \u2192 ${rtmpUrl} \u2192 mediamtx (local) \u2192 ${rtmpLocalUrl}
779
+ `);
780
+ }
781
+ } else if (useRtmpAuto && rtmpViaNgrok) {
782
+ mediamtxProc = mediamtxAuto;
783
+ const streamPath = rtmpStreamPath(rtmpUrl);
784
+ rtmpLocalUrl = `rtmp://localhost:1935/${streamPath}`;
785
+ realtimeEndpoints.push({
786
+ type: "rtmp",
787
+ url: rtmpUrl,
788
+ events: ["video_mixed_flv.data"]
789
+ });
790
+ process.stdout.write(`RTMP: recall.ai \u2192 ${rtmpUrl} (ngrok TCP) \u2192 mediamtx (local) \u2192 ${rtmpLocalUrl}
791
+ `);
792
+ } else {
793
+ rtmpLocalUrl = rtmpUrl;
794
+ realtimeEndpoints.push({
795
+ type: "rtmp",
796
+ url: rtmpUrl,
797
+ events: ["video_mixed_flv.data"]
798
+ });
799
+ process.stdout.write(`RTMP: recall.ai \u2192 ${rtmpUrl} (remote mediamtx; ffmpeg reads directly)
800
+ `);
801
+ }
802
+ }
803
+ const recordingConfig = {
804
+ transcript: {
805
+ provider: { deepgram_streaming: deepgramConfig },
806
+ diarization: { use_separate_streams_when_available: true }
807
+ },
808
+ screenshot: {},
809
+ realtime_endpoints: realtimeEndpoints
810
+ };
811
+ if (rtmpUrl) {
812
+ recordingConfig.video_mixed_flv = {};
813
+ }
814
+ if (useWsVideo) {
815
+ recordingConfig.video_separate_png = {};
816
+ }
817
+ const payload = {
818
+ meeting_url: args.url,
819
+ bot_name: name,
820
+ output_media: {
821
+ camera: {
822
+ kind: "webpage",
823
+ config: { url: AVATAR_URL }
824
+ }
825
+ },
826
+ recording_config: recordingConfig
827
+ };
828
+ const bot = await recall.createBot(payload);
829
+ const bid = bot.id;
830
+ const newState = {
831
+ bot_id: bid,
832
+ agent_name: args.name || "samoagent",
833
+ bot_name: name,
834
+ webhook_url: webhookUrl,
835
+ server_pid: server.pid,
836
+ ngrok_pid: ngrok.pid,
837
+ started_at: new Date().toISOString(),
838
+ dict: args.dict ?? null,
839
+ meeting_url: args.url,
840
+ transcript_file: transcriptFile
841
+ };
842
+ if (mediamtxProc) {
843
+ newState.mediamtx_pid = mediamtxProc.pid;
844
+ }
845
+ if (rtmpLocalUrl) {
846
+ newState.rtmp_local_url = rtmpLocalUrl;
847
+ }
848
+ if (useWsVideo) {
849
+ newState.local_frame_url = `http://127.0.0.1:${port}/frame`;
850
+ newState.local_frame_metadata_url = `http://127.0.0.1:${port}/frame.json`;
851
+ newState.frame_token = frameToken;
852
+ newState.video_frame_dir = videoFrameDir;
853
+ newState.video_frame_file = videoFrameFile;
854
+ }
855
+ saveState(newState);
856
+ stateSaved = true;
857
+ process.stdout.write(`Bot ID: ${bid}
858
+ `);
859
+ process.stdout.write(`Bot name: ${name}
860
+ `);
861
+ process.stdout.write(`Dict: ${args.dict || "none"} (${keyterms.length} terms)
862
+ `);
863
+ process.stdout.write(`Transcript -> ${transcriptFile}
864
+ `);
865
+ process.stdout.write(`
866
+ The agent will appear in the call within ~15 seconds.
867
+ `);
868
+ process.stdout.write(`
869
+ --- AGENT INSTRUCTIONS ---
870
+ `);
871
+ process.stdout.write(`IMPORTANT: You must now monitor the live transcript for the duration of the call.
872
+ `);
873
+ process.stdout.write(`Run this command with your Monitor tool (persistent=true):
874
+ `);
875
+ process.stdout.write(` samoagent watch
876
+ `);
877
+ process.stdout.write(`Each line you receive is a new utterance: [timestamp] Speaker: text
878
+ `);
879
+ process.stdout.write(`React to what is said. If someone addresses you or asks a question, respond in chat.
880
+ `);
881
+ process.stdout.write(`To send a message in the meeting chat: samoagent chat 'your message'
882
+ `);
883
+ if (rtmpLocalUrl) {
884
+ process.stdout.write(`To capture call frame: samoagent frame (ffmpeg from RTMP stream)
885
+ `);
886
+ } else if (useWsVideo) {
887
+ process.stdout.write(`To capture call frame: samoagent frame (latest WebSocket PNG, written on demand)
888
+ `);
889
+ } else {
890
+ process.stdout.write(`To capture what's on screen: samoagent screenshot (then Read screenshot.png)
891
+ `);
892
+ }
893
+ process.stdout.write(`To stop: samoagent leave
894
+ `);
895
+ process.stdout.write(`--------------------------
896
+ `);
897
+ } catch (err) {
898
+ cleanupUnsaved();
899
+ throw err;
900
+ }
901
+ }
902
+
903
+ // src/commands/leave.ts
904
+ import { existsSync as existsSync6, unlinkSync, appendFileSync } from "fs";
905
+ function defaultKill2(pid, signal) {
906
+ process.kill(pid, signal);
907
+ }
908
+ function fmtSentinelTs(d) {
909
+ const pad = (n) => String(n).padStart(2, "0");
910
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
911
+ }
912
+ async function cmdLeave(args, deps = {}) {
913
+ const recall = deps.recall ?? makeRecallClient();
914
+ const kill = deps.kill ?? defaultKill2;
915
+ const now = deps.now ?? (() => new Date);
916
+ const bid = botIdFromArgsOrState(args.bot_id);
917
+ try {
918
+ await recall.leaveCall(bid);
919
+ process.stdout.write(`Bot ${bid} left the call.
920
+ `);
921
+ } catch (e) {
922
+ process.stdout.write(`Warning: ${e}
923
+ `);
924
+ }
925
+ const state = loadState();
926
+ const transcriptFile = state.transcript_file;
927
+ if (typeof transcriptFile === "string" && transcriptFile) {
928
+ if (existsSync6(transcriptFile)) {
929
+ const ts = fmtSentinelTs(now());
930
+ try {
931
+ appendFileSync(transcriptFile, `[${ts}] SAMOAGENT_CALL_ENDED
932
+ `);
933
+ } catch {}
934
+ }
935
+ }
936
+ for (const pidKey of ["server_pid", "ngrok_pid", "mediamtx_pid"]) {
937
+ const pid = state[pidKey];
938
+ if (typeof pid === "number" && pid) {
939
+ try {
940
+ kill(pid, "SIGTERM");
941
+ process.stdout.write(`Stopped ${pidKey.replace("_pid", "")} (pid ${pid})
942
+ `);
943
+ } catch (e) {
944
+ if (e.code !== "ESRCH") {}
945
+ }
946
+ }
947
+ }
948
+ if (existsSync6(stateFile())) {
949
+ unlinkSync(stateFile());
950
+ }
951
+ process.stdout.write(`Done.
952
+ `);
953
+ }
954
+
955
+ // src/commands/status.ts
956
+ import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
957
+ async function cmdStatus(args, deps = {}) {
958
+ const recall = deps.recall ?? makeRecallClient();
959
+ const bid = botIdFromArgsOrState(args.bot_id);
960
+ const bot = await recall.getBot(bid);
961
+ const changes = bot.status_changes ?? [];
962
+ const status = changes.length ? changes[changes.length - 1].code ?? "unknown" : "joining";
963
+ const name = bot.bot_name ?? "?";
964
+ process.stdout.write(`Bot: ${bid}
965
+ `);
966
+ process.stdout.write(`Name: ${name}
967
+ `);
968
+ process.stdout.write(`Status: ${status}
969
+ `);
970
+ const state = loadState();
971
+ const tf = typeof state.transcript_file === "string" ? state.transcript_file : defaultTranscriptFile();
972
+ if (existsSync7(tf)) {
973
+ const lines = readFileSync5(tf, "utf-8").split(/\r?\n/).filter((l) => l.trim() && !SENTINEL_RE.test(l));
974
+ process.stdout.write(`Transcript lines so far: ${lines.length}
975
+ `);
976
+ process.stdout.write(`Transcript file: ${tf}
977
+ `);
978
+ }
979
+ }
980
+
981
+ // src/commands/screenshot.ts
982
+ import { resolve as resolve2 } from "path";
983
+ var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
984
+ function defaultRun(cmd) {
985
+ const proc = Bun.spawnSync(cmd);
986
+ return { exitCode: proc.exitCode };
987
+ }
988
+ function defaultFocus(meetingUrl) {
989
+ const domain = meetingUrl.includes("meet.google.com") ? "meet.google.com" : "zoom.us";
990
+ const script = `
991
+ tell application "Google Chrome"
992
+ set found to false
993
+ repeat with w in windows
994
+ set tabIdx to 0
995
+ repeat with t in tabs of w
996
+ set tabIdx to tabIdx + 1
997
+ if URL of t contains "${domain}" then
998
+ set active tab index of w to tabIdx
999
+ set index of w to 1
1000
+ set found to true
1001
+ exit repeat
1002
+ end if
1003
+ end repeat
1004
+ if found then exit repeat
1005
+ end repeat
1006
+ end tell
1007
+ tell application "Google Chrome" to activate
1008
+ `;
1009
+ try {
1010
+ Bun.spawnSync(["osascript", "-e", script], {
1011
+ timeout: 5000,
1012
+ stdout: "ignore",
1013
+ stderr: "ignore"
1014
+ });
1015
+ } catch {}
1016
+ }
1017
+ async function cmdScreenshot(args, deps = {}) {
1018
+ const run = deps.run ?? defaultRun;
1019
+ const focus = deps.focus ?? defaultFocus;
1020
+ const out = args.out || "screenshot.png";
1021
+ const state = loadState();
1022
+ const meetingUrl = state.meeting_url ?? "";
1023
+ if (meetingUrl.includes("meet.google.com") || meetingUrl.includes("zoom.us")) {
1024
+ focus(meetingUrl);
1025
+ await sleep3(1000);
1026
+ }
1027
+ const result = run(["screencapture", "-x", out]);
1028
+ if (result.exitCode !== 0) {
1029
+ throw new Error(`screencapture failed with code ${result.exitCode}`);
1030
+ }
1031
+ process.stdout.write(resolve2(out) + `
1032
+ `);
1033
+ }
1034
+
1035
+ // src/commands/transcript.ts
1036
+ async function cmdTranscript(args, deps = {}) {
1037
+ const recall = deps.recall ?? makeRecallClient();
1038
+ const fetchFn = deps.fetchFn ?? fetch;
1039
+ const bid = botIdFromArgsOrState(args.bot_id);
1040
+ const bot = await recall.getBot(bid);
1041
+ const recordings = bot.recordings ?? [];
1042
+ if (!recordings.length) {
1043
+ process.stdout.write(`No recordings yet.
1044
+ `);
1045
+ printLocalTranscript();
1046
+ return;
1047
+ }
1048
+ const media = recordings[0].media_shortcuts?.transcript ?? {};
1049
+ const statusCode = media.status?.code ?? "?";
1050
+ const downloadUrl = media.data?.download_url;
1051
+ if (downloadUrl) {
1052
+ const r = await fetchFn(downloadUrl, { signal: AbortSignal.timeout(30000) });
1053
+ const data = await r.json();
1054
+ for (const entry of data) {
1055
+ const words = (entry.words ?? []).map((w) => w.text ?? "").join(" ");
1056
+ const speaker = entry.speaker ?? "?";
1057
+ const start = entry.words?.[0]?.start_time ?? 0;
1058
+ process.stdout.write(`[${start.toFixed(1)}s] ${speaker}: ${words}
1059
+ `);
1060
+ }
1061
+ } else {
1062
+ process.stdout.write(`Transcript status: ${statusCode}
1063
+ `);
1064
+ printLocalTranscript();
1065
+ }
1066
+ }
1067
+
1068
+ // src/commands/chat.ts
1069
+ async function cmdChat(args, deps = {}) {
1070
+ const recall = deps.recall ?? makeRecallClient();
1071
+ const bid = botIdFromArgsOrState(args.bot_id);
1072
+ const resp = await recall.sendChat(bid, args.message ?? "");
1073
+ if (!resp.ok) {
1074
+ const body = await resp.text().catch(() => "");
1075
+ throw new Error(`send_chat_message failed: ${resp.status} ${body}`);
1076
+ }
1077
+ process.stdout.write(`Sent: ${args.message}
1078
+ `);
1079
+ }
1080
+
1081
+ // src/commands/frame.ts
1082
+ import { existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
1083
+ import { dirname as dirname4, resolve as resolve3 } from "path";
1084
+ function defaultRun2(cmd) {
1085
+ const proc = Bun.spawnSync(cmd, { timeout: 15000 });
1086
+ return { returncode: proc.exitCode, stderr: proc.stderr };
1087
+ }
1088
+ async function cmdFrame(args, deps = {}) {
1089
+ const recall = deps.recall ?? makeRecallClient();
1090
+ const fetchFn = deps.fetchFn ?? fetch;
1091
+ const run = deps.run ?? defaultRun2;
1092
+ const state = loadState();
1093
+ const archive = args.archive ?? false;
1094
+ const out = resolveFrameOutput(args.out, state);
1095
+ const rtmpLocalUrl = state.rtmp_local_url;
1096
+ if (typeof rtmpLocalUrl === "string" && rtmpLocalUrl) {
1097
+ let ffmpeg = "/opt/homebrew/bin/ffmpeg";
1098
+ if (!existsSync8(ffmpeg)) {
1099
+ ffmpeg = "ffmpeg";
1100
+ }
1101
+ const cmd = [
1102
+ ffmpeg,
1103
+ "-y",
1104
+ "-i",
1105
+ rtmpLocalUrl,
1106
+ "-vframes",
1107
+ "1",
1108
+ "-update",
1109
+ "1",
1110
+ "-q:v",
1111
+ "2",
1112
+ out
1113
+ ];
1114
+ const result = run(cmd);
1115
+ if (result.returncode === 0 && existsSync8(out)) {
1116
+ process.stdout.write(resolve3(out) + `
1117
+ `);
1118
+ return;
1119
+ }
1120
+ process.stderr.write(`FRAME_ERROR: ffmpeg failed to grab frame from ${rtmpLocalUrl}
1121
+ `);
1122
+ const errText = Buffer.from(result.stderr).toString("utf-8");
1123
+ process.stderr.write(errText.slice(-500) + `
1124
+ `);
1125
+ throw new ExitError(1);
1126
+ }
1127
+ const localFrameUrl = state.local_frame_url;
1128
+ if (typeof localFrameUrl === "string" && localFrameUrl) {
1129
+ const headers2 = {};
1130
+ if (typeof state.frame_token === "string" && state.frame_token) {
1131
+ headers2["X-Samoagent-Frame-Token"] = state.frame_token;
1132
+ }
1133
+ let resp2;
1134
+ try {
1135
+ resp2 = await fetchFn(localFrameUrl, { headers: headers2 });
1136
+ } catch (e) {
1137
+ process.stderr.write(`FRAME_UNAVAILABLE: local WebSocket frame server is not reachable: ${e instanceof Error ? e.message : String(e)}
1138
+ `);
1139
+ throw new ExitError(1);
1140
+ }
1141
+ const contentType2 = resp2.headers.get("content-type") ?? "";
1142
+ if (resp2.status === 200 && contentType2.startsWith("image/")) {
1143
+ let metadata = {};
1144
+ const metadataUrl = state.local_frame_metadata_url;
1145
+ if (typeof metadataUrl === "string" && metadataUrl) {
1146
+ try {
1147
+ const metaResp = await fetchFn(metadataUrl, { headers: headers2 });
1148
+ if (metaResp.status === 200) {
1149
+ metadata = await metaResp.json();
1150
+ }
1151
+ } catch {
1152
+ metadata = {};
1153
+ }
1154
+ }
1155
+ const raw = new Uint8Array(await resp2.arrayBuffer());
1156
+ const output = archive && !args.out ? archiveFrameBytes(String(state.video_frame_dir ?? dirname4(out)), raw, metadata) : out;
1157
+ if (!(archive && !args.out)) {
1158
+ writeFrameFiles(output, raw, metadata);
1159
+ }
1160
+ process.stdout.write(resolve3(output) + `
1161
+ `);
1162
+ return;
1163
+ }
1164
+ process.stderr.write(`FRAME_UNAVAILABLE: no WebSocket video frame received yet.
1165
+ `);
1166
+ process.stderr.write(`Wait for Recall to deliver video_separate_png.data, then retry.
1167
+ `);
1168
+ throw new ExitError(1);
1169
+ }
1170
+ const legacyFrameFile = state.video_frame_file;
1171
+ if (typeof legacyFrameFile === "string" && legacyFrameFile && existsSync8(legacyFrameFile)) {
1172
+ const output = archive && !args.out ? archiveExistingFrame(legacyFrameFile) : out;
1173
+ if (!(archive && !args.out)) {
1174
+ writeFileSync5(output, readFileSync6(legacyFrameFile));
1175
+ const metadataFile = frameMetadataPath(legacyFrameFile);
1176
+ if (existsSync8(metadataFile)) {
1177
+ writeFileSync5(frameMetadataPath(output), readFileSync6(metadataFile));
1178
+ }
1179
+ }
1180
+ process.stdout.write(resolve3(output) + `
1181
+ `);
1182
+ return;
1183
+ }
1184
+ const bid = botIdFromArgsOrState(args.bot_id);
1185
+ const resp = await recall.screenshot(bid);
1186
+ const contentType = resp.headers.get("content-type") ?? "";
1187
+ if (resp.status === 200 && contentType.startsWith("image/")) {
1188
+ const buf = new Uint8Array(await resp.arrayBuffer());
1189
+ writeFileSync5(out, buf);
1190
+ process.stdout.write(resolve3(out) + `
1191
+ `);
1192
+ return;
1193
+ }
1194
+ const meetingUrl = state.meeting_url ?? "";
1195
+ process.stderr.write(`FRAME_UNAVAILABLE: no RTMP stream configured and recall.ai live frame not available.
1196
+ `);
1197
+ process.stderr.write(`Options:
1198
+ `);
1199
+ process.stderr.write(` 1. Rejoin with --rtmp to enable RTMP frames via ngrok TCP (no VM needed; requires ngrok card on file)
1200
+ `);
1201
+ process.stderr.write(` 2. Rejoin with --rtmp-url rtmp://PUBLIC_IP:1935/live/call to enable RTMP frames via cloud VM
1202
+ `);
1203
+ process.stderr.write(` 3. Use browser tools to screenshot: ${meetingUrl}
1204
+ `);
1205
+ process.stderr.write(` 4. After call ends: samoagent transcript
1206
+ `);
1207
+ throw new ExitError(1);
1208
+ }
1209
+
1210
+ // src/commands/dicts.ts
1211
+ import { existsSync as existsSync9, readdirSync, readFileSync as readFileSync7 } from "fs";
1212
+ import { join as join6, basename } from "path";
1213
+ async function cmdDicts() {
1214
+ const dir = dictDir();
1215
+ if (!existsSync9(dir)) {
1216
+ process.stdout.write(`No dictionaries directory found.
1217
+ `);
1218
+ return;
1219
+ }
1220
+ const files = readdirSync(dir).filter((f) => f.endsWith(".txt")).sort().map((f) => join6(dir, f));
1221
+ if (!files.length) {
1222
+ process.stdout.write(`No dictionaries found.
1223
+ `);
1224
+ return;
1225
+ }
1226
+ process.stdout.write(`Available dictionaries:
1227
+ `);
1228
+ for (const f of files) {
1229
+ const terms = readFileSync7(f, "utf-8").split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
1230
+ const stem = basename(f, ".txt");
1231
+ process.stdout.write(` ${stem} (${terms.length} terms)
1232
+ `);
1233
+ }
1234
+ }
1235
+
1236
+ // src/commands/watch.ts
1237
+ async function cmdWatch() {
1238
+ await watch();
1239
+ }
1240
+
1241
+ // src/server.ts
1242
+ import { appendFileSync as appendFileSync2 } from "fs";
1243
+ import { readFileSync as readFileSync8 } from "fs";
1244
+ import { Buffer as Buffer3 } from "buffer";
1245
+ var WEBHOOK_MAX_BYTES = 1024 * 1024;
1246
+ async function handleWebhook(payload, transcriptPath) {
1247
+ const line = formatTranscriptLine(payload);
1248
+ if (line !== null) {
1249
+ appendFileSync2(transcriptPath, line + `
1250
+ `);
1251
+ }
1252
+ }
1253
+ function callIdFromStateFile(path) {
1254
+ if (!path)
1255
+ return null;
1256
+ try {
1257
+ const state = JSON.parse(readFileSync8(path, "utf-8"));
1258
+ return typeof state.bot_id === "string" ? state.bot_id : null;
1259
+ } catch {
1260
+ return null;
1261
+ }
1262
+ }
1263
+ function serve(port, transcriptPath, options = {}) {
1264
+ const opts = typeof options === "string" || options === null ? { webhookToken: options } : options;
1265
+ const latestVideoFrame = { raw: null, metadata: null };
1266
+ const frameAuthorized = (req) => Boolean(opts.frameToken) && req.headers.get("X-Samoagent-Frame-Token") === opts.frameToken;
1267
+ return Bun.serve({
1268
+ port,
1269
+ hostname: "0.0.0.0",
1270
+ async fetch(req, server) {
1271
+ const url = new URL(req.url);
1272
+ if (req.method === "POST" && url.pathname === "/webhook") {
1273
+ if (!opts.webhookToken || url.searchParams.get("token") !== opts.webhookToken) {
1274
+ return Response.json({ error: "forbidden" }, { status: 403 });
1275
+ }
1276
+ const contentLength = req.headers.get("content-length");
1277
+ if (contentLength !== null && Number(contentLength) > WEBHOOK_MAX_BYTES) {
1278
+ return Response.json({ error: "payload too large" }, { status: 413 });
1279
+ }
1280
+ let payload = {};
1281
+ try {
1282
+ const body = await req.text();
1283
+ if (body.length > WEBHOOK_MAX_BYTES) {
1284
+ return Response.json({ error: "payload too large" }, { status: 413 });
1285
+ }
1286
+ payload = body ? JSON.parse(body) : {};
1287
+ } catch {
1288
+ payload = {};
1289
+ }
1290
+ await handleWebhook(payload, transcriptPath);
1291
+ return Response.json({ ok: true });
1292
+ }
1293
+ if (req.method === "GET" && url.pathname === "/frame") {
1294
+ if (!frameAuthorized(req)) {
1295
+ return new Response("", { status: 403 });
1296
+ }
1297
+ if (latestVideoFrame.raw === null) {
1298
+ return new Response("", { status: 404 });
1299
+ }
1300
+ return new Response(latestVideoFrame.raw, {
1301
+ headers: { "Content-Type": "image/png" }
1302
+ });
1303
+ }
1304
+ if (req.method === "GET" && url.pathname === "/frame.json") {
1305
+ if (!frameAuthorized(req)) {
1306
+ return Response.json({ error: "forbidden" }, { status: 403 });
1307
+ }
1308
+ if (latestVideoFrame.metadata === null) {
1309
+ return Response.json({ error: "no frame" }, { status: 404 });
1310
+ }
1311
+ return Response.json(latestVideoFrame.metadata);
1312
+ }
1313
+ if (url.pathname === "/video-ws") {
1314
+ if (!opts.frameToken || url.searchParams.get("token") !== opts.frameToken) {
1315
+ return new Response("", { status: 403 });
1316
+ }
1317
+ const upgraded = server.upgrade(req);
1318
+ if (upgraded)
1319
+ return;
1320
+ return new Response("Upgrade Required", { status: 426 });
1321
+ }
1322
+ return new Response("Not Found", { status: 404 });
1323
+ },
1324
+ websocket: {
1325
+ message(_ws, message) {
1326
+ let payload;
1327
+ try {
1328
+ const text = typeof message === "string" ? message : Buffer3.from(message).toString("utf-8");
1329
+ payload = JSON.parse(text);
1330
+ } catch {
1331
+ return;
1332
+ }
1333
+ const decoded = decodeVideoSeparatePng(payload, opts.currentCallId?.() ?? null);
1334
+ if (decoded === null)
1335
+ return;
1336
+ latestVideoFrame.raw = decoded.raw;
1337
+ latestVideoFrame.metadata = decoded.metadata;
1338
+ }
1339
+ }
1340
+ });
1341
+ }
1342
+
1343
+ // src/commands/serve.ts
1344
+ async function cmdServe(args) {
1345
+ const port = args.port || 8080;
1346
+ const transcriptPath = args.transcript_file;
1347
+ serve(port, transcriptPath, {
1348
+ webhookToken: args.webhook_token,
1349
+ frameToken: args.frame_token,
1350
+ currentCallId: () => callIdFromStateFile(args.call_id_file)
1351
+ });
1352
+ await new Promise(() => {});
1353
+ }
1354
+
1355
+ // src/cli.ts
1356
+ var USAGE = `usage: samoagent <command> [options]
1357
+
1358
+ AI meeting agent for Zoom & Google Meet
1359
+
1360
+ commands:
1361
+ join <url> [--name N] [--dict D] [--port P] [--transcript-dir DIR] [--rtmp-url URL] [--rtmp] [--no-ws-video] [--frame-dir DIR]
1362
+ leave [bot_id]
1363
+ status [bot_id]
1364
+ screenshot [--out FILE] [bot_id]
1365
+ chat <message> [--bot-id ID]
1366
+ transcript [bot_id]
1367
+ dicts
1368
+ watch
1369
+ frame [--out FILE] [--archive] [bot_id]
1370
+ `;
1371
+
1372
+ class ArgError extends Error {
1373
+ }
1374
+ function parseArgs(argv) {
1375
+ if (argv.length === 0) {
1376
+ throw new ArgError("the following arguments are required: command");
1377
+ }
1378
+ const command = argv[0];
1379
+ const rest = argv.slice(1);
1380
+ const positionals = [];
1381
+ const opts = {};
1382
+ const valueFlags = {
1383
+ join: new Set(["--name", "--dict", "--port", "--transcript-dir", "--rtmp-url", "--frame-dir"]),
1384
+ leave: new Set,
1385
+ status: new Set,
1386
+ screenshot: new Set(["--out"]),
1387
+ chat: new Set(["--bot-id"]),
1388
+ transcript: new Set,
1389
+ dicts: new Set,
1390
+ watch: new Set,
1391
+ frame: new Set(["--out"]),
1392
+ _serve: new Set(["--port", "--transcript-file", "--webhook-token", "--call-id-file", "--frame-token"])
1393
+ };
1394
+ const boolFlags = {
1395
+ join: new Set(["--rtmp", "--no-ws-video"]),
1396
+ leave: new Set,
1397
+ status: new Set,
1398
+ screenshot: new Set,
1399
+ chat: new Set,
1400
+ transcript: new Set,
1401
+ dicts: new Set,
1402
+ watch: new Set,
1403
+ frame: new Set(["--archive"]),
1404
+ _serve: new Set
1405
+ };
1406
+ const knownCommands = Object.keys(valueFlags);
1407
+ if (!knownCommands.includes(command)) {
1408
+ throw new ArgError(`invalid choice: '${command}'`);
1409
+ }
1410
+ const vFlags = valueFlags[command];
1411
+ const bFlags = boolFlags[command];
1412
+ for (let i = 0;i < rest.length; i++) {
1413
+ const tok = rest[i];
1414
+ if (tok.startsWith("--")) {
1415
+ const eq = tok.indexOf("=");
1416
+ if (eq !== -1) {
1417
+ const flag = tok.slice(0, eq);
1418
+ const val = tok.slice(eq + 1);
1419
+ if (vFlags.has(flag)) {
1420
+ opts[flag] = val;
1421
+ } else {
1422
+ throw new ArgError(`unrecognized arguments: ${tok}`);
1423
+ }
1424
+ continue;
1425
+ }
1426
+ if (bFlags.has(tok)) {
1427
+ opts[tok] = true;
1428
+ continue;
1429
+ }
1430
+ if (vFlags.has(tok)) {
1431
+ const val = rest[i + 1];
1432
+ if (val === undefined) {
1433
+ throw new ArgError(`argument ${tok}: expected one argument`);
1434
+ }
1435
+ opts[tok] = val;
1436
+ i += 1;
1437
+ continue;
1438
+ }
1439
+ throw new ArgError(`unrecognized arguments: ${tok}`);
1440
+ } else {
1441
+ positionals.push(tok);
1442
+ }
1443
+ }
1444
+ const result = { command };
1445
+ switch (command) {
1446
+ case "join": {
1447
+ if (positionals.length < 1) {
1448
+ throw new ArgError("the following arguments are required: url");
1449
+ }
1450
+ result.url = positionals[0];
1451
+ result.name = opts["--name"] ?? null;
1452
+ result.dict = opts["--dict"] ?? null;
1453
+ const rawPort = opts["--port"];
1454
+ if (rawPort !== undefined) {
1455
+ const p = Number(rawPort);
1456
+ if (!Number.isInteger(p) || p < 1 || p > 65535) {
1457
+ throw new ArgError(`argument --port: invalid port number: '${rawPort}'`);
1458
+ }
1459
+ result.port = p;
1460
+ } else {
1461
+ result.port = 8080;
1462
+ }
1463
+ result.transcript_dir = opts["--transcript-dir"] ?? null;
1464
+ result.rtmp_url = opts["--rtmp-url"] ?? null;
1465
+ result.rtmp = opts["--rtmp"] === true;
1466
+ result.ws_video = opts["--no-ws-video"] !== true;
1467
+ result.frame_dir = opts["--frame-dir"] ?? null;
1468
+ break;
1469
+ }
1470
+ case "leave":
1471
+ case "status":
1472
+ case "transcript": {
1473
+ result.bot_id = positionals.length ? positionals[0] : null;
1474
+ break;
1475
+ }
1476
+ case "screenshot": {
1477
+ result.out = opts["--out"] ?? "screenshot.png";
1478
+ result.bot_id = positionals.length ? positionals[0] : null;
1479
+ break;
1480
+ }
1481
+ case "frame": {
1482
+ result.out = opts["--out"] ?? null;
1483
+ result.archive = opts["--archive"] === true;
1484
+ result.bot_id = positionals.length ? positionals[0] : null;
1485
+ break;
1486
+ }
1487
+ case "chat": {
1488
+ if (positionals.length < 1) {
1489
+ throw new ArgError("the following arguments are required: message");
1490
+ }
1491
+ result.message = positionals[0];
1492
+ result.bot_id = opts["--bot-id"] ?? null;
1493
+ break;
1494
+ }
1495
+ case "dicts":
1496
+ case "watch":
1497
+ break;
1498
+ case "_serve": {
1499
+ const rawPort2 = opts["--port"];
1500
+ if (rawPort2 !== undefined) {
1501
+ const p2 = Number(rawPort2);
1502
+ if (!Number.isInteger(p2) || p2 < 1 || p2 > 65535) {
1503
+ throw new ArgError(`argument --port: invalid port number: '${rawPort2}'`);
1504
+ }
1505
+ result.port = p2;
1506
+ } else {
1507
+ result.port = 8080;
1508
+ }
1509
+ if (opts["--transcript-file"] === undefined) {
1510
+ throw new ArgError("the following arguments are required: --transcript-file");
1511
+ }
1512
+ result.transcript_file = opts["--transcript-file"];
1513
+ result.webhook_token = opts["--webhook-token"] ?? "";
1514
+ result.call_id_file = opts["--call-id-file"] ?? "";
1515
+ result.frame_token = opts["--frame-token"] ?? "";
1516
+ break;
1517
+ }
1518
+ }
1519
+ return result;
1520
+ }
1521
+ async function dispatch(args) {
1522
+ switch (args.command) {
1523
+ case "join":
1524
+ return cmdJoin(args);
1525
+ case "leave":
1526
+ return cmdLeave(args);
1527
+ case "status":
1528
+ return cmdStatus(args);
1529
+ case "screenshot":
1530
+ return cmdScreenshot(args);
1531
+ case "transcript":
1532
+ return cmdTranscript(args);
1533
+ case "chat":
1534
+ return cmdChat(args);
1535
+ case "frame":
1536
+ return cmdFrame(args);
1537
+ case "dicts":
1538
+ return cmdDicts();
1539
+ case "watch":
1540
+ return cmdWatch();
1541
+ case "_serve":
1542
+ return cmdServe(args);
1543
+ default:
1544
+ throw new ArgError(`invalid choice: '${args.command}'`);
1545
+ }
1546
+ }
1547
+ async function main() {
1548
+ const argv = process.argv.slice(2);
1549
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h" || argv.includes("--help") || argv.includes("-h")) {
1550
+ process.stdout.write(USAGE);
1551
+ process.exit(argv.length === 0 ? 2 : 0);
1552
+ }
1553
+ let args;
1554
+ try {
1555
+ args = parseArgs(argv);
1556
+ } catch (e) {
1557
+ if (e instanceof ArgError) {
1558
+ process.stderr.write(`samoagent: error: ${e.message}
1559
+ `);
1560
+ process.exit(2);
1561
+ }
1562
+ throw e;
1563
+ }
1564
+ try {
1565
+ await dispatch(args);
1566
+ } catch (e) {
1567
+ if (e instanceof ExitError) {
1568
+ process.exit(e.code);
1569
+ }
1570
+ process.stderr.write(`samoagent: error: ${e instanceof Error ? e.message : String(e)}
1571
+ `);
1572
+ process.exit(1);
1573
+ }
1574
+ }
1575
+ if (import.meta.main) {
1576
+ main();
1577
+ }
1578
+ export {
1579
+ parseArgs
1580
+ };