samograph 0.6.1 → 0.7.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.
Files changed (3) hide show
  1. package/README.md +21 -5
  2. package/dist/cli.js +761 -295
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -5,15 +5,29 @@ var __getProtoOf = Object.getPrototypeOf;
5
5
  var __defProp = Object.defineProperty;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ function __accessProp(key) {
9
+ return this[key];
10
+ }
11
+ var __toESMCache_node;
12
+ var __toESMCache_esm;
8
13
  var __toESM = (mod, isNodeMode, target) => {
14
+ var canCache = mod != null && typeof mod === "object";
15
+ if (canCache) {
16
+ var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
17
+ var cached = cache.get(mod);
18
+ if (cached)
19
+ return cached;
20
+ }
9
21
  target = mod != null ? __create(__getProtoOf(mod)) : {};
10
22
  const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
11
23
  for (let key of __getOwnPropNames(mod))
12
24
  if (!__hasOwnProp.call(to, key))
13
25
  __defProp(to, key, {
14
- get: () => mod[key],
26
+ get: __accessProp.bind(mod, key),
15
27
  enumerable: true
16
28
  });
29
+ if (canCache)
30
+ cache.set(mod, to);
17
31
  return to;
18
32
  };
19
33
  var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
@@ -22,7 +36,7 @@ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports,
22
36
  var require_package = __commonJS((exports, module) => {
23
37
  module.exports = {
24
38
  name: "samograph",
25
- version: "0.6.1",
39
+ version: "0.7.0",
26
40
  description: "Let AI agents join Zoom and Google Meet calls as active participants.",
27
41
  type: "module",
28
42
  license: "Apache-2.0",
@@ -160,7 +174,15 @@ function newPresenceSnapshot(state = "listening", message = defaultPresenceMessa
160
174
  state,
161
175
  message,
162
176
  updated_at: new Date().toISOString(),
163
- activities
177
+ activities,
178
+ chime: null
179
+ };
180
+ }
181
+ function withChime(snapshot) {
182
+ return {
183
+ ...snapshot,
184
+ updated_at: new Date().toISOString(),
185
+ chime: { at: new Date().toISOString() }
164
186
  };
165
187
  }
166
188
  var ACTIVITY_LIMIT = 16;
@@ -410,6 +432,9 @@ function presencePageHtml() {
410
432
  overflow: hidden;
411
433
  display: flex;
412
434
  flex-direction: column;
435
+ /* Pin the stack to the top: the first line appears at the top and each
436
+ new line is added below it (newest at the bottom, chat order). Once the
437
+ lane is full, the oldest lines overflow off the top. */
413
438
  justify-content: flex-start;
414
439
  }
415
440
  .item {
@@ -695,6 +720,54 @@ function presencePageHtml() {
695
720
  resize();
696
721
  requestAnimationFrame(draw);
697
722
  }
723
+ // Soft "blip" audio cue, synthesized with WebAudio (no asset files). Played
724
+ // once per new chime timestamp \u2014 e.g. when the bot posts a meeting-chat
725
+ // message \u2014 so people notice it without watching the camera. Recall renders
726
+ // this page as the bot camera and streams its audio into the call.
727
+ let audioCtx = null;
728
+ function chimeAudioContext() {
729
+ if (!audioCtx) {
730
+ const Ctx = window.AudioContext || window.webkitAudioContext;
731
+ if (!Ctx) return null;
732
+ try { audioCtx = new Ctx(); } catch { return null; }
733
+ }
734
+ if (audioCtx.state === "suspended") audioCtx.resume().catch(() => {});
735
+ return audioCtx;
736
+ }
737
+ function playChime() {
738
+ const ctx = chimeAudioContext();
739
+ if (!ctx) return;
740
+ const t = ctx.currentTime;
741
+ const osc = ctx.createOscillator();
742
+ const gain = ctx.createGain();
743
+ const lp = ctx.createBiquadFilter();
744
+ lp.type = "lowpass";
745
+ lp.frequency.value = 1800; // shave harshness for a soft tone
746
+ osc.type = "sine";
747
+ // A gentle upward "bl-ip" rather than a flat beep.
748
+ osc.frequency.setValueAtTime(540, t);
749
+ osc.frequency.exponentialRampToValueAtTime(680, t + 0.06);
750
+ // Quick soft attack, smooth decay; low peak gain so it stays unobtrusive.
751
+ gain.gain.setValueAtTime(0.0001, t);
752
+ gain.gain.exponentialRampToValueAtTime(0.16, t + 0.012);
753
+ gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.28);
754
+ osc.connect(lp);
755
+ lp.connect(gain);
756
+ gain.connect(ctx.destination);
757
+ osc.start(t);
758
+ osc.stop(t + 0.32);
759
+ }
760
+ let lastChimeAt = "";
761
+ let chimeReady = false;
762
+ function handleChime(chime) {
763
+ const at = chime && chime.at ? String(chime.at) : "";
764
+ // First poll establishes a baseline without playing, so a chime already
765
+ // sitting in the snapshot at page load does not fire on join.
766
+ if (!chimeReady) { lastChimeAt = at; chimeReady = true; return; }
767
+ if (!at || at === lastChimeAt) return;
768
+ lastChimeAt = at;
769
+ playChime();
770
+ }
698
771
  function formatUpdated(value) {
699
772
  const date = new Date(String(value || ""));
700
773
  if (Number.isNaN(date.getTime())) return "Waiting for live signal";
@@ -726,8 +799,10 @@ function presencePageHtml() {
726
799
  element.append(empty);
727
800
  return;
728
801
  }
802
+ // items arrive newest-first; render oldest-first so the newest line lands
803
+ // at the bottom of the lane (chat order). Keep only the newest 14.
729
804
  let lastLabel = "";
730
- for (const item of items.slice(0, 14)) {
805
+ for (const item of items.slice(0, 14).reverse()) {
731
806
  const row = document.createElement("div");
732
807
  row.className = "item";
733
808
  const label = document.createElement("div");
@@ -743,6 +818,12 @@ function presencePageHtml() {
743
818
  element.append(row);
744
819
  }
745
820
  }
821
+ // Adaptive polling: every poll is a request through the public tunnel
822
+ // (the page is loaded by Recall via the tunnel URL, so same-origin
823
+ // fetches ride it too). Poll fast only while the snapshot is changing;
824
+ // back off when the call goes quiet to preserve tunnel request quota.
825
+ let lastSignature = "";
826
+ let lastActivityAt = Date.now();
746
827
  async function refresh() {
747
828
  try {
748
829
  const response = await fetch("/presence.json", {
@@ -751,6 +832,12 @@ function presencePageHtml() {
751
832
  });
752
833
  if (!response.ok) return;
753
834
  const data = await response.json();
835
+ handleChime(data.chime);
836
+ const signature = String(data.updated_at || "");
837
+ if (signature !== lastSignature) {
838
+ lastSignature = signature;
839
+ lastActivityAt = Date.now();
840
+ }
754
841
  const state = String(data.state || "listening");
755
842
  document.getElementById("live").textContent = state;
756
843
  const buckets = { heard: [], comment: [] };
@@ -773,23 +860,33 @@ function presencePageHtml() {
773
860
  document.documentElement.style.setProperty("--accent-mid", pair[2]);
774
861
  } catch {}
775
862
  }
863
+ function nextPollDelay() {
864
+ // 1 s while the snapshot changed within the last 30 s, else 5 s.
865
+ return Date.now() - lastActivityAt < 30000 ? 1000 : 5000;
866
+ }
867
+ async function pollLoop() {
868
+ await refresh();
869
+ setTimeout(pollLoop, nextPollDelay());
870
+ }
776
871
  if (backgroundMode === "robot") {
777
872
  initRobot();
778
873
  } else {
779
874
  initPlasma();
780
875
  initFpsProbe();
781
- refresh();
782
- setInterval(refresh, 1000);
783
876
  }
877
+ // Poll on every background, including the static robot avatar: the loop
878
+ // drives the chime cue and live activity lanes, which must not depend on
879
+ // the animated plasma backgrounds being active.
880
+ pollLoop();
784
881
  </script>
785
882
  </body>
786
883
  </html>`;
787
884
  }
788
885
 
789
886
  // src/commands/join.ts
790
- import { writeFileSync as writeFileSync4 } from "fs";
791
- import { spawn as spawnChild } from "child_process";
792
- import { randomUUID } from "crypto";
887
+ import { writeFileSync as writeFileSync5 } from "fs";
888
+ import { spawn as spawnChild2 } from "child_process";
889
+ import { randomUUID as randomUUID2 } from "crypto";
793
890
  import { fileURLToPath as fileURLToPath2 } from "url";
794
891
 
795
892
  // src/transcript.ts
@@ -1270,6 +1367,14 @@ function makeRecallClient(fetchFn = fetch) {
1270
1367
  signal: AbortSignal.timeout(1e4)
1271
1368
  });
1272
1369
  },
1370
+ async outputAudio(botId, b64Mp3) {
1371
+ return fetchFn(`${RECALL_BASE}/bot/${botId}/output_audio/`, {
1372
+ method: "POST",
1373
+ headers: headers(),
1374
+ body: JSON.stringify({ kind: "mp3", b64_data: b64Mp3 }),
1375
+ signal: AbortSignal.timeout(15000)
1376
+ });
1377
+ },
1273
1378
  async screenshot(botId) {
1274
1379
  return fetchFn(`${RECALL_BASE}/bot/${botId}/screenshot/`, {
1275
1380
  method: "GET",
@@ -1294,20 +1399,501 @@ function makeRecallClient(fetchFn = fetch) {
1294
1399
  };
1295
1400
  }
1296
1401
 
1402
+ // src/introText.ts
1403
+ var DEFAULT_INTRO_TEXT = "Hi, I'm Leo, a samograph assistant bot \uD83E\uDD16 During the call I follow the " + "live transcript and can help in real time: post messages here in chat (with " + "a soft chime), show my status on the bot camera, capture the screen-share or " + "a participant's video on request, and keep shared notes in a Google Doc. " + "Just mention me in chat if you need anything.";
1404
+
1405
+ // src/chimeAudio.ts
1406
+ var CHIME_MP3_BASE64 = "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" + "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqr/+5LE8IIaGgj0Dr0rivbA3uHGIbmqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" + "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqoAUakj" + "rtRWckiBjGZ5j8FpxFKFGjBLBRAkGlws9jjfBoPkCNAuw9yFEVSmkqNDIyURwtJeDb2VlVlUl1INRrc///2Mk4T1mUZKlipwo25q" + "KypYqVKI4XSaU55v/919/+soipVRGw/NjKVw/xEQlji7DeayiVGSiNh7pKrLKpTpUiEI0NF0E6VLHShRGw9mSqS88ldbn//yUa+Z" + "KMooiERB8PkE83JRlV16ismklOG5sZXcNzclFZaxKqIFpd9NTEFNRTMuMTAwVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + "//uSxJgD17XquExlJsgAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + "VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQ==";
1407
+
1408
+ // src/commands/chat.ts
1409
+ async function ringCallAudio(recall, botId) {
1410
+ try {
1411
+ const resp = await recall.outputAudio(botId, CHIME_MP3_BASE64);
1412
+ if (!resp.ok)
1413
+ await resp.text().catch(() => "");
1414
+ } catch {}
1415
+ }
1416
+ async function ringChime(fetchFn) {
1417
+ try {
1418
+ const state = loadState();
1419
+ const presenceUrl = state.local_presence_update_url;
1420
+ const token = state.presence_write_token;
1421
+ if (typeof presenceUrl !== "string" || !presenceUrl)
1422
+ return;
1423
+ if (typeof token !== "string" || !token)
1424
+ return;
1425
+ const u = new URL(presenceUrl);
1426
+ u.pathname = "/chime";
1427
+ await fetchFn(u.toString(), {
1428
+ method: "POST",
1429
+ headers: { "X-Samograph-Presence-Token": token }
1430
+ });
1431
+ } catch {}
1432
+ }
1433
+ async function cmdChat(args, deps = {}) {
1434
+ const recall = deps.recall ?? makeRecallClient();
1435
+ const bid = botIdFromArgsOrState(args.bot_id);
1436
+ const resp = await recall.sendChat(bid, args.message ?? "");
1437
+ if (!resp.ok) {
1438
+ const body = await resp.text().catch(() => "");
1439
+ throw new Error(`send_chat_message failed: ${resp.status} ${body}`);
1440
+ }
1441
+ await ringCallAudio(recall, bid);
1442
+ await ringChime(deps.fetchFn ?? fetch);
1443
+ process.stdout.write(`Sent: ${args.message}
1444
+ `);
1445
+ }
1446
+
1447
+ // src/commands/intro.ts
1448
+ var UTTERANCE_RE = /^\[[^\]]+\]\s+([^:]+):\s*(.*)$/;
1449
+ function firstHeardLine(path) {
1450
+ for (const line of localTranscriptLines(path)) {
1451
+ const m = line.match(UTTERANCE_RE);
1452
+ if (!m)
1453
+ continue;
1454
+ const speaker = m[1].trim();
1455
+ const text = m[2].trim();
1456
+ if (!text || speaker.startsWith("SAMOGRAPH-WARNING"))
1457
+ continue;
1458
+ return { speaker, text };
1459
+ }
1460
+ return null;
1461
+ }
1462
+ function buildIntroMessage(args, transcriptPath) {
1463
+ const custom = args.intro_text?.trim();
1464
+ let message = custom && custom.length ? custom : DEFAULT_INTRO_TEXT;
1465
+ if (args.context) {
1466
+ const first = firstHeardLine(transcriptPath);
1467
+ if (first) {
1468
+ message += `
1469
+
1470
+ The first thing I heard was \u2014 ${first.speaker}: "${first.text}"`;
1471
+ }
1472
+ }
1473
+ return message;
1474
+ }
1475
+ async function cmdIntro(args, deps = {}) {
1476
+ const recall = deps.recall ?? makeRecallClient();
1477
+ const message = buildIntroMessage(args, deps.transcriptPath);
1478
+ await cmdChat({ command: "chat", message, bot_id: args.bot_id ?? null }, { recall, fetchFn: deps.fetchFn });
1479
+ }
1480
+ var IN_CALL_STATUSES = new Set([
1481
+ "in_call_recording",
1482
+ "in_call_not_recording"
1483
+ ]);
1484
+ function latestStatusCode(bot) {
1485
+ const changes = bot?.status_changes;
1486
+ if (Array.isArray(changes) && changes.length) {
1487
+ return String(changes[changes.length - 1]?.code ?? "");
1488
+ }
1489
+ return "";
1490
+ }
1491
+ var realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
1492
+ async function postIntroOnJoin(recall, botId, text, opts = {}) {
1493
+ const tries = opts.tries ?? 12;
1494
+ const delayMs = opts.delayMs ?? 2500;
1495
+ const sleepFn = opts.sleepFn ?? realSleep;
1496
+ for (let i = 0;i < tries; i++) {
1497
+ try {
1498
+ const bot = await recall.getBot(botId);
1499
+ if (IN_CALL_STATUSES.has(latestStatusCode(bot))) {
1500
+ const resp = await recall.sendChat(botId, text);
1501
+ if (resp.ok) {
1502
+ process.stdout.write(`Posted intro to meeting chat.
1503
+ `);
1504
+ return true;
1505
+ }
1506
+ process.stderr.write(`Note: posting the intro returned HTTP ${resp.status}. ` + `Run 'samograph intro' to retry if it did not appear.
1507
+ `);
1508
+ return false;
1509
+ }
1510
+ } catch {}
1511
+ if (i < tries - 1)
1512
+ await sleepFn(delayMs);
1513
+ }
1514
+ process.stderr.write("Note: could not post the intro yet (bot not in the call). " + `Run 'samograph intro' once it has joined.
1515
+ `);
1516
+ return false;
1517
+ }
1518
+
1519
+ // src/server.ts
1520
+ import { appendFileSync } from "fs";
1521
+ import { readFileSync as readFileSync5 } from "fs";
1522
+ import { Buffer as Buffer3 } from "buffer";
1523
+ import { createHash, randomUUID, timingSafeEqual } from "crypto";
1524
+ var WEBHOOK_MAX_BYTES = 1024 * 1024;
1525
+ var HEALTH_MARKER = "samograph-health";
1526
+ async function probeTunnelHealth(publicBaseUrl, fetchFn = fetch, nonceFn = randomUUID) {
1527
+ const nonce = nonceFn();
1528
+ try {
1529
+ const response = await fetchFn(`${publicBaseUrl}/health?nonce=${encodeURIComponent(nonce)}`, { cache: "no-store", signal: AbortSignal.timeout(5000) });
1530
+ const headerCode = response.headers.get("ngrok-error-code");
1531
+ if (headerCode) {
1532
+ return { ok: false, ngrokErrorCode: headerCode.trim() };
1533
+ }
1534
+ if (response.ok) {
1535
+ const body = await response.json().catch(() => null);
1536
+ if (body !== null && body.nonce === nonce && body.marker === HEALTH_MARKER) {
1537
+ return { ok: true, ngrokErrorCode: null };
1538
+ }
1539
+ return { ok: false, ngrokErrorCode: null };
1540
+ }
1541
+ const text = await response.text().catch(() => "");
1542
+ const bodyCode = text.match(/ERR_NGROK_\d+/);
1543
+ return { ok: false, ngrokErrorCode: bodyCode ? bodyCode[0] : null };
1544
+ } catch {
1545
+ return { ok: false, ngrokErrorCode: null };
1546
+ }
1547
+ }
1548
+ var TUNNEL_WATCHDOG_INTERVAL_MS = 60000;
1549
+ var TUNNEL_WATCHDOG_FAILURE_THRESHOLD = 2;
1550
+ function fmtTranscriptTs(d) {
1551
+ const pad = (n) => String(n).padStart(2, "0");
1552
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
1553
+ }
1554
+ function defaultSchedule(fn, ms) {
1555
+ const timer = setInterval(fn, ms);
1556
+ timer.unref?.();
1557
+ return { stop: () => clearInterval(timer) };
1558
+ }
1559
+ function startTunnelWatchdog(options) {
1560
+ const publicBase = (options.publicBase ?? "").replace(/\/+$/, "");
1561
+ if (!publicBase)
1562
+ return null;
1563
+ const fetchFn = options.fetch ?? fetch;
1564
+ const nonceFn = options.nonce ?? randomUUID;
1565
+ const now = options.now ?? (() => new Date);
1566
+ const writeStderr = options.stderr ?? ((s) => void process.stderr.write(s));
1567
+ let consecutiveFailures = 0;
1568
+ let inOutage = false;
1569
+ const emit = (text) => {
1570
+ const line = `[${fmtTranscriptTs(now())}] ${text}`;
1571
+ try {
1572
+ appendFileSync(options.transcriptPath, line + `
1573
+ `);
1574
+ } catch {}
1575
+ writeStderr(line + `
1576
+ `);
1577
+ };
1578
+ const tick = async () => {
1579
+ const probe = await probeTunnelHealth(publicBase, fetchFn, nonceFn);
1580
+ if (probe.ok) {
1581
+ consecutiveFailures = 0;
1582
+ if (inOutage) {
1583
+ inOutage = false;
1584
+ emit("SAMOGRAPH-WARNING: tunnel recovered - live transcript delivery resumed");
1585
+ }
1586
+ return;
1587
+ }
1588
+ consecutiveFailures += 1;
1589
+ if (consecutiveFailures >= TUNNEL_WATCHDOG_FAILURE_THRESHOLD && !inOutage) {
1590
+ inOutage = true;
1591
+ const cause = probe.ngrokErrorCode ?? "health check failed";
1592
+ emit(`SAMOGRAPH-WARNING: tunnel unreachable (${cause}) - transcript may be ` + "incomplete; rejoin with --tunnel cloudflared or --webhook-base");
1593
+ }
1594
+ };
1595
+ const scheduled = (options.schedule ?? defaultSchedule)(() => void tick(), options.intervalMs ?? TUNNEL_WATCHDOG_INTERVAL_MS);
1596
+ return { tick, stop: () => scheduled.stop() };
1597
+ }
1598
+ function tokensEqual(a, b) {
1599
+ if (!a || !b)
1600
+ return false;
1601
+ const ha = createHash("sha256").update(a).digest();
1602
+ const hb = createHash("sha256").update(b).digest();
1603
+ return timingSafeEqual(ha, hb);
1604
+ }
1605
+ async function handleWebhook(payload, transcriptPath) {
1606
+ const line = formatTranscriptLine(payload);
1607
+ if (line !== null) {
1608
+ appendFileSync(transcriptPath, line + `
1609
+ `);
1610
+ }
1611
+ return line;
1612
+ }
1613
+ function selectedFrame(latest, bySource, source) {
1614
+ const key = normalizeFrameSource(source);
1615
+ return key ? bySource.get(key) ?? { raw: null, metadata: null } : latest;
1616
+ }
1617
+ function frameInventory(bySource) {
1618
+ const seen = new Set;
1619
+ const frames = [];
1620
+ for (const frame of bySource.values()) {
1621
+ const sourceKey = frame.metadata?.source_key;
1622
+ if (!frame.metadata || !sourceKey || seen.has(sourceKey))
1623
+ continue;
1624
+ seen.add(sourceKey);
1625
+ frames.push(frame.metadata);
1626
+ }
1627
+ frames.sort((a, b) => String(a.source_key).localeCompare(String(b.source_key)));
1628
+ return frames;
1629
+ }
1630
+ function callIdFromStateFile(path) {
1631
+ if (!path)
1632
+ return null;
1633
+ try {
1634
+ const state = JSON.parse(readFileSync5(path, "utf-8"));
1635
+ return typeof state.bot_id === "string" ? state.bot_id : null;
1636
+ } catch {
1637
+ return null;
1638
+ }
1639
+ }
1640
+ function serve(port, transcriptPath, options = {}) {
1641
+ const opts = typeof options === "string" || options === null ? { webhookToken: options } : options;
1642
+ const latestVideoFrame = { raw: null, metadata: null };
1643
+ let presence = newPresenceSnapshot();
1644
+ const framesBySource = new Map;
1645
+ const frameAuthorized = (req) => tokensEqual(req.headers.get("X-Samograph-Frame-Token"), opts.frameToken);
1646
+ const presencePageAuthorized = (req, url) => tokensEqual(req.headers.get("X-Samograph-Presence-Token"), opts.presenceToken) || tokensEqual(url.searchParams.get("token"), opts.presenceToken);
1647
+ const presenceJsonAuthorized = (req) => tokensEqual(req.headers.get("X-Samograph-Presence-Token"), opts.presenceToken);
1648
+ const presenceWriteAuthorized = (req) => tokensEqual(req.headers.get("X-Samograph-Presence-Token"), opts.presenceWriteToken);
1649
+ return Bun.serve({
1650
+ port,
1651
+ hostname: "127.0.0.1",
1652
+ maxRequestBodySize: WEBHOOK_MAX_BYTES,
1653
+ async fetch(req, server) {
1654
+ const url = new URL(req.url);
1655
+ if (req.method === "POST" && url.pathname === "/webhook") {
1656
+ if (!tokensEqual(url.searchParams.get("token"), opts.webhookToken)) {
1657
+ return Response.json({ error: "forbidden" }, { status: 403 });
1658
+ }
1659
+ const contentLength = req.headers.get("content-length");
1660
+ if (contentLength !== null && Number(contentLength) > WEBHOOK_MAX_BYTES) {
1661
+ return Response.json({ error: "payload too large" }, { status: 413 });
1662
+ }
1663
+ let payload = {};
1664
+ try {
1665
+ const body = await req.text();
1666
+ if (new TextEncoder().encode(body).byteLength > WEBHOOK_MAX_BYTES) {
1667
+ return Response.json({ error: "payload too large" }, { status: 413 });
1668
+ }
1669
+ payload = body ? JSON.parse(body) : {};
1670
+ } catch {
1671
+ payload = {};
1672
+ }
1673
+ const transcriptLine = await handleWebhook(payload, transcriptPath);
1674
+ if (transcriptLine !== null) {
1675
+ const activity = activityFromTranscriptLine(transcriptLine);
1676
+ if (activity !== null) {
1677
+ presence = appendPresenceActivity(presence, activity);
1678
+ }
1679
+ }
1680
+ return Response.json({ ok: true });
1681
+ }
1682
+ if (req.method === "GET" && url.pathname === "/health") {
1683
+ return Response.json({
1684
+ ok: true,
1685
+ nonce: url.searchParams.get("nonce") ?? "",
1686
+ marker: HEALTH_MARKER
1687
+ });
1688
+ }
1689
+ if (req.method === "GET" && url.pathname === "/frame") {
1690
+ if (!frameAuthorized(req)) {
1691
+ return new Response("", { status: 403 });
1692
+ }
1693
+ const frame = selectedFrame(latestVideoFrame, framesBySource, url.searchParams.get("source"));
1694
+ if (frame.raw === null) {
1695
+ return new Response("", { status: 404 });
1696
+ }
1697
+ return new Response(frame.raw, {
1698
+ headers: { "Content-Type": "image/png" }
1699
+ });
1700
+ }
1701
+ if (req.method === "GET" && url.pathname === "/frame.json") {
1702
+ if (!frameAuthorized(req)) {
1703
+ return Response.json({ error: "forbidden" }, { status: 403 });
1704
+ }
1705
+ const frame = selectedFrame(latestVideoFrame, framesBySource, url.searchParams.get("source"));
1706
+ if (frame.metadata === null) {
1707
+ return Response.json({ error: "no frame" }, { status: 404 });
1708
+ }
1709
+ return Response.json(frame.metadata);
1710
+ }
1711
+ if (req.method === "GET" && url.pathname === "/frames.json") {
1712
+ if (!frameAuthorized(req)) {
1713
+ return Response.json({ error: "forbidden" }, { status: 403 });
1714
+ }
1715
+ return Response.json({ frames: frameInventory(framesBySource) });
1716
+ }
1717
+ if (req.method === "GET" && url.pathname === "/presence") {
1718
+ if (!presencePageAuthorized(req, url)) {
1719
+ return new Response("", { status: 403 });
1720
+ }
1721
+ return new Response(presencePageHtml(), {
1722
+ headers: {
1723
+ "Content-Type": "text/html; charset=utf-8",
1724
+ "Cache-Control": "no-store"
1725
+ }
1726
+ });
1727
+ }
1728
+ if (req.method === "GET" && url.pathname === "/presence.json") {
1729
+ if (!presenceJsonAuthorized(req)) {
1730
+ return Response.json({ error: "forbidden" }, { status: 403 });
1731
+ }
1732
+ return Response.json(presence, {
1733
+ headers: { "Cache-Control": "no-store" }
1734
+ });
1735
+ }
1736
+ if (req.method === "POST" && url.pathname === "/presence") {
1737
+ if (!presenceWriteAuthorized(req)) {
1738
+ return Response.json({ error: "forbidden" }, { status: 403 });
1739
+ }
1740
+ const contentLength = req.headers.get("content-length");
1741
+ if (contentLength !== null && Number(contentLength) > WEBHOOK_MAX_BYTES) {
1742
+ return Response.json({ error: "payload too large" }, { status: 413 });
1743
+ }
1744
+ let payload = {};
1745
+ try {
1746
+ const body = await req.text();
1747
+ if (new TextEncoder().encode(body).byteLength > WEBHOOK_MAX_BYTES) {
1748
+ return Response.json({ error: "payload too large" }, { status: 413 });
1749
+ }
1750
+ payload = body ? JSON.parse(body) : {};
1751
+ } catch {
1752
+ payload = {};
1753
+ }
1754
+ const rawPayload = payload;
1755
+ const state = normalizePresenceState(rawPayload.state);
1756
+ if (state === null) {
1757
+ return Response.json({ error: "invalid presence state" }, { status: 400 });
1758
+ }
1759
+ const hasMessage = typeof rawPayload.message === "string" && sanitizePresenceText(rawPayload.message) !== "";
1760
+ const message = hasMessage ? sanitizePresenceMessage(rawPayload.message, state) : defaultPresenceMessage(state);
1761
+ if (hasMessage) {
1762
+ presence = appendPresenceActivity(presence, {
1763
+ kind: activityKindForState(state),
1764
+ label: labelForPresenceState(state),
1765
+ text: message
1766
+ });
1767
+ }
1768
+ presence = newPresenceSnapshot(state, message, presence.activities);
1769
+ return Response.json({ ok: true, presence });
1770
+ }
1771
+ if (req.method === "POST" && url.pathname === "/chime") {
1772
+ if (!presenceWriteAuthorized(req)) {
1773
+ return Response.json({ error: "forbidden" }, { status: 403 });
1774
+ }
1775
+ presence = withChime(presence);
1776
+ return Response.json({ ok: true, chime: presence.chime });
1777
+ }
1778
+ if (url.pathname === "/video-ws") {
1779
+ if (!tokensEqual(url.searchParams.get("token"), opts.frameToken)) {
1780
+ return new Response("", { status: 403 });
1781
+ }
1782
+ const upgraded = server.upgrade(req);
1783
+ if (upgraded)
1784
+ return;
1785
+ return new Response("Upgrade Required", { status: 426 });
1786
+ }
1787
+ return new Response("Not Found", { status: 404 });
1788
+ },
1789
+ websocket: {
1790
+ message(_ws, message) {
1791
+ let payload;
1792
+ try {
1793
+ const text = typeof message === "string" ? message : Buffer3.from(message).toString("utf-8");
1794
+ payload = JSON.parse(text);
1795
+ } catch {
1796
+ return;
1797
+ }
1798
+ const decoded = decodeVideoSeparatePng(payload, opts.currentCallId?.() ?? null);
1799
+ if (decoded === null)
1800
+ return;
1801
+ latestVideoFrame.raw = decoded.raw;
1802
+ latestVideoFrame.metadata = decoded.metadata;
1803
+ const frame = { raw: decoded.raw, metadata: decoded.metadata };
1804
+ for (const alias of frameSourceAliases(decoded.metadata)) {
1805
+ framesBySource.set(alias, frame);
1806
+ }
1807
+ }
1808
+ }
1809
+ });
1810
+ }
1811
+
1812
+ // src/tunnel.ts
1813
+ import { spawn as spawnChild } from "child_process";
1814
+ import { mkdirSync as mkdirSync4, openSync as openSync2, closeSync as closeSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
1815
+ import { join as join5 } from "path";
1816
+ var CLOUDFLARED_URL_RE = /https:\/\/[a-z0-9][a-z0-9-]*\.trycloudflare\.com(?![\w.-])/i;
1817
+ function parseCloudflaredUrl(text) {
1818
+ const match = text.match(CLOUDFLARED_URL_RE);
1819
+ return match ? match[0] : null;
1820
+ }
1821
+ function cloudflaredBinary(env = process.env) {
1822
+ return env.CLOUDFLARED_BIN || "cloudflared";
1823
+ }
1824
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
1825
+ var CLOUDFLARED_URL_ATTEMPTS = 40;
1826
+ var CLOUDFLARED_URL_SLEEP_MS = 750;
1827
+ async function waitForCloudflaredUrl(readText, sleepFn = sleep2, attempts = CLOUDFLARED_URL_ATTEMPTS, aborted = () => false) {
1828
+ for (let i = 0;i < attempts; i++) {
1829
+ const url = parseCloudflaredUrl(readText());
1830
+ if (url)
1831
+ return url;
1832
+ if (aborted())
1833
+ return null;
1834
+ if (i < attempts - 1)
1835
+ await sleepFn(CLOUDFLARED_URL_SLEEP_MS);
1836
+ }
1837
+ return null;
1838
+ }
1839
+ async function startCloudflared(port) {
1840
+ const dir = samographDir();
1841
+ mkdirSync4(dir, { recursive: true });
1842
+ const logPath = join5(dir, "cloudflared.log");
1843
+ writeFileSync4(logPath, "");
1844
+ const fd = openSync2(logPath, "a");
1845
+ let spawnFailed = false;
1846
+ const child = spawnChild(cloudflaredBinary(), ["tunnel", "--url", `http://127.0.0.1:${port}`], { detached: true, stdio: ["ignore", "ignore", fd] });
1847
+ child.on("error", () => {
1848
+ spawnFailed = true;
1849
+ });
1850
+ child.on("exit", () => {
1851
+ spawnFailed = true;
1852
+ });
1853
+ child.unref();
1854
+ const url = await waitForCloudflaredUrl(() => {
1855
+ try {
1856
+ return readFileSync6(logPath, "utf-8");
1857
+ } catch {
1858
+ return "";
1859
+ }
1860
+ }, sleep2, CLOUDFLARED_URL_ATTEMPTS, () => spawnFailed);
1861
+ closeSync2(fd);
1862
+ if (url === null || typeof child.pid !== "number") {
1863
+ try {
1864
+ child.kill("SIGTERM");
1865
+ } catch {}
1866
+ return null;
1867
+ }
1868
+ return {
1869
+ proc: {
1870
+ get pid() {
1871
+ return child.pid;
1872
+ },
1873
+ kill() {
1874
+ try {
1875
+ child.kill("SIGTERM");
1876
+ } catch {}
1877
+ }
1878
+ },
1879
+ url
1880
+ };
1881
+ }
1882
+
1297
1883
  // src/rtmp.ts
1298
1884
  import {
1299
1885
  existsSync as existsSync5,
1300
- mkdirSync as mkdirSync4,
1886
+ mkdirSync as mkdirSync5,
1301
1887
  copyFileSync as copyFileSync2,
1302
1888
  chmodSync as chmodSync3
1303
1889
  } from "fs";
1304
- import { join as join5 } from "path";
1890
+ import { join as join6 } from "path";
1305
1891
  import { homedir as homedir4 } from "os";
1306
1892
  function rtmpStreamPath(rtmpUrl) {
1307
1893
  const parsed = new URL(rtmpUrl);
1308
1894
  return parsed.pathname.replace(/^\/+/, "");
1309
1895
  }
1310
- var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
1896
+ var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
1311
1897
  async function ngrokApiPort(fetchFn = fetch) {
1312
1898
  for (const p of [4040, 4041, 4042, 4043]) {
1313
1899
  try {
@@ -1342,7 +1928,7 @@ async function waitForNgrok(port, timeout = 15, fetchFn = fetch) {
1342
1928
  return tunnels[0].public_url;
1343
1929
  }
1344
1930
  } catch {}
1345
- await sleep2(1000);
1931
+ await sleep3(1000);
1346
1932
  }
1347
1933
  return null;
1348
1934
  }
@@ -1386,7 +1972,7 @@ async function ensureMediamtx() {
1386
1972
  if (inPath) {
1387
1973
  return inPath;
1388
1974
  }
1389
- const localBin = join5(homedir4(), ".samograph", "bin", "mediamtx");
1975
+ const localBin = join6(homedir4(), ".samograph", "bin", "mediamtx");
1390
1976
  if (existsSync5(localBin)) {
1391
1977
  return localBin;
1392
1978
  }
@@ -1401,20 +1987,20 @@ async function ensureMediamtx() {
1401
1987
  const url = `https://github.com/bluenviron/mediamtx/releases/download/${version}/${filename}`;
1402
1988
  process.stdout.write(`Downloading mediamtx ${version}...
1403
1989
  `);
1404
- const tmpdir = join5(homedir4(), ".samograph", "tmp-mediamtx");
1405
- mkdirSync4(tmpdir, { recursive: true });
1406
- const archivePath = join5(tmpdir, filename);
1990
+ const tmpdir = join6(homedir4(), ".samograph", "tmp-mediamtx");
1991
+ mkdirSync5(tmpdir, { recursive: true });
1992
+ const archivePath = join6(tmpdir, filename);
1407
1993
  const resp = await fetch(url);
1408
1994
  if (!resp.ok) {
1409
1995
  throw new Error(`Failed to download mediamtx: ${resp.status}`);
1410
1996
  }
1411
1997
  await Bun.write(archivePath, resp);
1412
1998
  await Bun.$`tar -xzf ${archivePath} -C ${tmpdir} mediamtx`.quiet();
1413
- const extracted = join5(tmpdir, "mediamtx");
1999
+ const extracted = join6(tmpdir, "mediamtx");
1414
2000
  if (!existsSync5(extracted)) {
1415
2001
  throw new Error("mediamtx binary not found in downloaded archive");
1416
2002
  }
1417
- mkdirSync4(join5(localBin, ".."), { recursive: true });
2003
+ mkdirSync5(join6(localBin, ".."), { recursive: true });
1418
2004
  copyFileSync2(extracted, localBin);
1419
2005
  chmodSync3(localBin, 493);
1420
2006
  return localBin;
@@ -1425,7 +2011,7 @@ async function startMediamtx() {
1425
2011
  stdout: "ignore",
1426
2012
  stderr: "ignore"
1427
2013
  });
1428
- await sleep2(1500);
2014
+ await sleep3(1500);
1429
2015
  if (proc.exitCode !== null) {
1430
2016
  return null;
1431
2017
  }
@@ -1438,7 +2024,7 @@ function defaultKill(pid, signal) {
1438
2024
  process.kill(pid, signal);
1439
2025
  } catch {}
1440
2026
  }
1441
- function spawnDetached(cmd, opts = {}, spawnFn = spawnChild) {
2027
+ function spawnDetached(cmd, opts = {}, spawnFn = spawnChild2) {
1442
2028
  const [command, ...args] = cmd;
1443
2029
  if (!command) {
1444
2030
  throw new Error("cannot spawn an empty command");
@@ -1462,13 +2048,43 @@ function spawnDetached(cmd, opts = {}, spawnFn = spawnChild) {
1462
2048
  };
1463
2049
  }
1464
2050
  var PRESENCE_PAGE_MARKER = "samograph-presence";
2051
+ var TUNNEL_HEALTH_ATTEMPTS = 40;
2052
+ var TUNNEL_HEALTH_SLEEP_MS = 750;
2053
+ var NGROK_ERROR_HINTS = {
2054
+ ERR_NGROK_727: "account HTTP request limit exceeded"
2055
+ };
2056
+ async function checkTunnelHealth(publicBaseUrl, fetchFn = fetch, sleepFn = sleep4, attempts = TUNNEL_HEALTH_ATTEMPTS, nonceFn = randomUUID2) {
2057
+ for (let i = 0;i < attempts; i++) {
2058
+ const probe = await probeTunnelHealth(publicBaseUrl, fetchFn, nonceFn);
2059
+ if (probe.ok) {
2060
+ return probe;
2061
+ }
2062
+ if (probe.ngrokErrorCode) {
2063
+ return probe;
2064
+ }
2065
+ if (i < attempts - 1) {
2066
+ await sleepFn(TUNNEL_HEALTH_SLEEP_MS);
2067
+ }
2068
+ }
2069
+ return { ok: false, ngrokErrorCode: null };
2070
+ }
2071
+ function tunnelHealthFailureMessage(result) {
2072
+ const options = "Options: --tunnel cloudflared (free, no request limits), " + "--webhook-base with your own tunnel, or upgrade ngrok.";
2073
+ if (result.ngrokErrorCode) {
2074
+ const hint = NGROK_ERROR_HINTS[result.ngrokErrorCode] ?? "see https://ngrok.com/docs/errors";
2075
+ return `Error: tunnel is not relaying requests (ngrok error ${result.ngrokErrorCode}: ${hint}). ` + `The bot would join but receive no transcript. ${options}
2076
+ `;
2077
+ }
2078
+ return "Error: tunnel is not relaying requests (the public /health round-trip never " + "returned this server's response \u2014 interstitial page, unreachable URL, or a " + "tunnel pointed at something else). " + `The bot would join but receive no transcript. ${options}
2079
+ `;
2080
+ }
1465
2081
  var PRESENCE_PREFLIGHT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36";
1466
2082
  var PRESENCE_PREFLIGHT_ATTEMPTS = 40;
1467
2083
  var PRESENCE_PREFLIGHT_SLEEP_MS = 750;
1468
- async function sleep3(ms) {
2084
+ async function sleep4(ms) {
1469
2085
  await new Promise((resolve2) => setTimeout(resolve2, ms));
1470
2086
  }
1471
- async function waitForPresenceCamera(url, fetchFn = fetch, sleepFn = sleep3, attempts = PRESENCE_PREFLIGHT_ATTEMPTS) {
2087
+ async function waitForPresenceCamera(url, fetchFn = fetch, sleepFn = sleep4, attempts = PRESENCE_PREFLIGHT_ATTEMPTS) {
1472
2088
  for (let i = 0;i < attempts; i++) {
1473
2089
  try {
1474
2090
  const response = await fetchFn(url, {
@@ -1493,16 +2109,17 @@ async function cmdJoin(args, deps = {}) {
1493
2109
  const kill = deps.kill ?? defaultKill;
1494
2110
  const spawn = deps.spawn ?? spawnDetached;
1495
2111
  const waitForNgrokFn = deps.waitForNgrok ?? waitForNgrok;
2112
+ const startCloudflaredFn = deps.startCloudflared ?? startCloudflared;
1496
2113
  const startMediamtxFn = deps.startMediamtx ?? startMediamtx;
1497
2114
  const startNgrokTcpTunnelFn = deps.startNgrokTcpTunnel ?? startNgrokTcpTunnel;
1498
2115
  const fetchFn = deps.fetch ?? fetch;
1499
- const sleepFn = deps.sleep ?? sleep3;
2116
+ const sleepFn = deps.sleep ?? sleep4;
1500
2117
  const transcriptFile = resolveNewTranscriptFile(args.transcript_dir);
1501
- writeFileSync4(transcriptFile, "", { flag: "wx", mode: 384 });
1502
- const webhookToken = randomUUID();
1503
- const frameToken = randomUUID();
1504
- const presenceToken = randomUUID();
1505
- const presenceWriteToken = randomUUID();
2118
+ writeFileSync5(transcriptFile, "", { flag: "wx", mode: 384 });
2119
+ const webhookToken = randomUUID2();
2120
+ const frameToken = randomUUID2();
2121
+ const presenceToken = randomUUID2();
2122
+ const presenceWriteToken = randomUUID2();
1506
2123
  const keyterms = loadDict(args.dict);
1507
2124
  const name = botName(args.name);
1508
2125
  const port = args.port || 8080;
@@ -1512,7 +2129,7 @@ async function cmdJoin(args, deps = {}) {
1512
2129
  const videoFrameDir = resolveVideoFrameDir(args.frame_dir, false);
1513
2130
  const videoFrameFile = resolveVideoFrameFile(args.frame_dir, false);
1514
2131
  const oldState = loadState();
1515
- for (const pidKey of ["server_pid", "ngrok_pid", "mediamtx_pid"]) {
2132
+ for (const pidKey of ["server_pid", "ngrok_pid", "tunnel_pid", "mediamtx_pid"]) {
1516
2133
  const pid = oldState[pidKey];
1517
2134
  if (typeof pid === "number" && pid) {
1518
2135
  kill(pid, "SIGTERM");
@@ -1520,25 +2137,7 @@ async function cmdJoin(args, deps = {}) {
1520
2137
  }
1521
2138
  const selfPath = fileURLToPath2(import.meta.url);
1522
2139
  const cliPath = selfPath.replace(/commands\/join\.ts$/, "cli.ts");
1523
- const server = spawn([
1524
- process.execPath,
1525
- cliPath,
1526
- "_serve",
1527
- "--port",
1528
- String(port),
1529
- "--transcript-file",
1530
- transcriptFile,
1531
- "--call-id-file",
1532
- stateFile()
1533
- ], {
1534
- env: {
1535
- SAMOGRAPH_WEBHOOK_TOKEN: webhookToken,
1536
- SAMOGRAPH_FRAME_TOKEN: frameToken,
1537
- SAMOGRAPH_PRESENCE_TOKEN: presenceToken,
1538
- SAMOGRAPH_PRESENCE_WRITE_TOKEN: presenceWriteToken
1539
- }
1540
- });
1541
- const started = new Set([server]);
2140
+ const started = new Set;
1542
2141
  let stateSaved = false;
1543
2142
  const cleanupUnsaved = () => {
1544
2143
  if (stateSaved)
@@ -1576,15 +2175,32 @@ async function cmdJoin(args, deps = {}) {
1576
2175
  if (webhookBase !== null) {
1577
2176
  webhookBase = normalizeWebhookBase(webhookBase);
1578
2177
  }
1579
- const ngrok = webhookBase ? null : spawn(["ngrok", "http", String(port), "--log=stdout"]);
2178
+ const useCloudflared = (args.tunnel ?? "ngrok") === "cloudflared";
2179
+ const ngrok = webhookBase || useCloudflared ? null : spawn(["ngrok", "http", String(port), "--log=stdout"]);
1580
2180
  if (ngrok)
1581
2181
  started.add(ngrok);
2182
+ let cloudflared = null;
1582
2183
  try {
1583
2184
  let webhookUrl;
1584
2185
  if (webhookBase) {
1585
2186
  process.stdout.write(`Using external tunnel (--webhook-base): ${webhookBase} \u2192 localhost:${port}
1586
2187
  `);
1587
2188
  webhookUrl = webhookBase;
2189
+ } else if (useCloudflared) {
2190
+ process.stdout.write(`Starting cloudflared tunnel on port ${port}...
2191
+ `);
2192
+ const tunnel = await startCloudflaredFn(port);
2193
+ if (!tunnel) {
2194
+ process.stderr.write("Error: cloudflared tunnel failed to start. Install cloudflared " + "(e.g. brew install cloudflared) or point CLOUDFLARED_BIN at the " + `binary; alternatively use --webhook-base or the default ngrok tunnel.
2195
+ `);
2196
+ cleanupUnsaved();
2197
+ throw new ExitError(1);
2198
+ }
2199
+ cloudflared = tunnel.proc;
2200
+ started.add(cloudflared);
2201
+ webhookUrl = tunnel.url;
2202
+ process.stdout.write(`cloudflared tunnel: ${webhookUrl} \u2192 localhost:${port}
2203
+ `);
1588
2204
  } else {
1589
2205
  process.stdout.write(`Starting ngrok tunnel on port ${port}...
1590
2206
  `);
@@ -1597,6 +2213,33 @@ async function cmdJoin(args, deps = {}) {
1597
2213
  throw new ExitError(1);
1598
2214
  }
1599
2215
  const publicBaseUrl = webhookUrl.replace(/\/+$/, "");
2216
+ const server = spawn([
2217
+ process.execPath,
2218
+ cliPath,
2219
+ "_serve",
2220
+ "--port",
2221
+ String(port),
2222
+ "--transcript-file",
2223
+ transcriptFile,
2224
+ "--call-id-file",
2225
+ stateFile(),
2226
+ "--public-base",
2227
+ publicBaseUrl
2228
+ ], {
2229
+ env: {
2230
+ SAMOGRAPH_WEBHOOK_TOKEN: webhookToken,
2231
+ SAMOGRAPH_FRAME_TOKEN: frameToken,
2232
+ SAMOGRAPH_PRESENCE_TOKEN: presenceToken,
2233
+ SAMOGRAPH_PRESENCE_WRITE_TOKEN: presenceWriteToken
2234
+ }
2235
+ });
2236
+ started.add(server);
2237
+ const health = await checkTunnelHealth(publicBaseUrl, fetchFn, sleepFn);
2238
+ if (!health.ok) {
2239
+ process.stderr.write(tunnelHealthFailureMessage(health));
2240
+ cleanupUnsaved();
2241
+ throw new ExitError(1);
2242
+ }
1600
2243
  const presenceBgSuffix = args.presence_bg ? `&bg=${encodeURIComponent(args.presence_bg)}` : "";
1601
2244
  let presencePageUrl = args.no_presence ? null : `${publicBaseUrl}/presence?token=${encodeURIComponent(presenceToken)}${presenceBgSuffix}`;
1602
2245
  webhookUrl = `${publicBaseUrl}/webhook?token=${encodeURIComponent(webhookToken)}`;
@@ -1774,6 +2417,9 @@ async function cmdJoin(args, deps = {}) {
1774
2417
  newState.presence_token = presenceToken;
1775
2418
  newState.presence_write_token = presenceWriteToken;
1776
2419
  }
2420
+ if (cloudflared) {
2421
+ newState.tunnel_pid = cloudflared.pid;
2422
+ }
1777
2423
  if (mediamtxProc) {
1778
2424
  newState.mediamtx_pid = mediamtxProc.pid;
1779
2425
  }
@@ -1835,6 +2481,10 @@ The agent will appear in the call within ~15 seconds.
1835
2481
  `);
1836
2482
  process.stdout.write(`--------------------------
1837
2483
  `);
2484
+ if (args.intro) {
2485
+ const introText = args.intro_text && args.intro_text.trim() ? args.intro_text.trim() : DEFAULT_INTRO_TEXT;
2486
+ postIntroOnJoin(recall, bid, introText);
2487
+ }
1838
2488
  } catch (err) {
1839
2489
  cleanupUnsaved();
1840
2490
  throw err;
@@ -1842,7 +2492,7 @@ The agent will appear in the call within ~15 seconds.
1842
2492
  }
1843
2493
 
1844
2494
  // src/commands/leave.ts
1845
- import { existsSync as existsSync6, unlinkSync, appendFileSync } from "fs";
2495
+ import { existsSync as existsSync6, unlinkSync, appendFileSync as appendFileSync2 } from "fs";
1846
2496
  function defaultKill2(pid, signal) {
1847
2497
  process.kill(pid, signal);
1848
2498
  }
@@ -1869,12 +2519,12 @@ async function cmdLeave(args, deps = {}) {
1869
2519
  if (existsSync6(transcriptFile)) {
1870
2520
  const ts = fmtSentinelTs(now());
1871
2521
  try {
1872
- appendFileSync(transcriptFile, `[${ts}] SAMOGRAPH_CALL_ENDED
2522
+ appendFileSync2(transcriptFile, `[${ts}] SAMOGRAPH_CALL_ENDED
1873
2523
  `);
1874
2524
  } catch {}
1875
2525
  }
1876
2526
  }
1877
- for (const pidKey of ["server_pid", "ngrok_pid", "mediamtx_pid"]) {
2527
+ for (const pidKey of ["server_pid", "ngrok_pid", "tunnel_pid", "mediamtx_pid"]) {
1878
2528
  const pid = state[pidKey];
1879
2529
  if (typeof pid === "number" && pid) {
1880
2530
  try {
@@ -1894,7 +2544,7 @@ async function cmdLeave(args, deps = {}) {
1894
2544
  }
1895
2545
 
1896
2546
  // src/commands/status.ts
1897
- import { existsSync as existsSync7, readFileSync as readFileSync5, statSync } from "fs";
2547
+ import { existsSync as existsSync7, readFileSync as readFileSync7, statSync } from "fs";
1898
2548
  async function cmdStatus(args, deps = {}) {
1899
2549
  const recall = deps.recall ?? makeRecallClient();
1900
2550
  const fetchFn = deps.fetchFn ?? fetch;
@@ -1912,7 +2562,7 @@ async function cmdStatus(args, deps = {}) {
1912
2562
  const state = loadState();
1913
2563
  const tf = typeof state.transcript_file === "string" ? state.transcript_file : defaultTranscriptFile();
1914
2564
  if (existsSync7(tf)) {
1915
- const lines = readFileSync5(tf, "utf-8").split(/\r?\n/).filter((l) => l.trim() && !SENTINEL_RE.test(l));
2565
+ const lines = readFileSync7(tf, "utf-8").split(/\r?\n/).filter((l) => l.trim() && !SENTINEL_RE.test(l));
1916
2566
  process.stdout.write(`Transcript lines so far: ${lines.length}
1917
2567
  `);
1918
2568
  if (lines.length) {
@@ -1993,7 +2643,7 @@ async function cmdStatus(args, deps = {}) {
1993
2643
 
1994
2644
  // src/commands/screenshot.ts
1995
2645
  import { resolve as resolve2 } from "path";
1996
- var sleep4 = (ms) => new Promise((r) => setTimeout(r, ms));
2646
+ var sleep5 = (ms) => new Promise((r) => setTimeout(r, ms));
1997
2647
  function defaultRun(cmd) {
1998
2648
  const proc = Bun.spawnSync(cmd);
1999
2649
  return { exitCode: proc.exitCode };
@@ -2035,7 +2685,7 @@ async function cmdScreenshot(args, deps = {}) {
2035
2685
  const meetingUrl = state.meeting_url ?? "";
2036
2686
  if (meetingUrl.includes("meet.google.com") || meetingUrl.includes("zoom.us")) {
2037
2687
  focus(meetingUrl);
2038
- await sleep4(1000);
2688
+ await sleep5(1000);
2039
2689
  }
2040
2690
  const result = run(["screencapture", "-x", out]);
2041
2691
  if (result.exitCode !== 0) {
@@ -2103,21 +2753,8 @@ async function cmdTranscript(args, deps = {}) {
2103
2753
  }
2104
2754
  }
2105
2755
 
2106
- // src/commands/chat.ts
2107
- async function cmdChat(args, deps = {}) {
2108
- const recall = deps.recall ?? makeRecallClient();
2109
- const bid = botIdFromArgsOrState(args.bot_id);
2110
- const resp = await recall.sendChat(bid, args.message ?? "");
2111
- if (!resp.ok) {
2112
- const body = await resp.text().catch(() => "");
2113
- throw new Error(`send_chat_message failed: ${resp.status} ${body}`);
2114
- }
2115
- process.stdout.write(`Sent: ${args.message}
2116
- `);
2117
- }
2118
-
2119
2756
  // src/commands/frame.ts
2120
- import { existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
2757
+ import { existsSync as existsSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
2121
2758
  import { dirname as dirname4, resolve as resolve3 } from "path";
2122
2759
  function defaultRun2(cmd) {
2123
2760
  const proc = Bun.spawnSync(cmd, { timeout: 15000 });
@@ -2215,10 +2852,10 @@ async function cmdFrame(args, deps = {}) {
2215
2852
  if (typeof legacyFrameFile === "string" && legacyFrameFile && existsSync8(legacyFrameFile)) {
2216
2853
  const output = archive && !args.out ? archiveExistingFrame(legacyFrameFile) : out;
2217
2854
  if (!(archive && !args.out)) {
2218
- writeFileSync5(output, readFileSync6(legacyFrameFile));
2855
+ writeFileSync6(output, readFileSync8(legacyFrameFile));
2219
2856
  const metadataFile = frameMetadataPath(legacyFrameFile);
2220
2857
  if (existsSync8(metadataFile)) {
2221
- writeFileSync5(frameMetadataPath(output), readFileSync6(metadataFile));
2858
+ writeFileSync6(frameMetadataPath(output), readFileSync8(metadataFile));
2222
2859
  }
2223
2860
  }
2224
2861
  process.stdout.write(resolve3(output) + `
@@ -2230,7 +2867,7 @@ async function cmdFrame(args, deps = {}) {
2230
2867
  const contentType = resp.headers.get("content-type") ?? "";
2231
2868
  if (resp.status === 200 && contentType.startsWith("image/")) {
2232
2869
  const buf = new Uint8Array(await resp.arrayBuffer());
2233
- writeFileSync5(out, buf);
2870
+ writeFileSync6(out, buf);
2234
2871
  process.stdout.write(resolve3(out) + `
2235
2872
  `);
2236
2873
  return;
@@ -2299,8 +2936,8 @@ async function cmdFrames(deps = {}) {
2299
2936
  }
2300
2937
 
2301
2938
  // src/commands/dicts.ts
2302
- import { existsSync as existsSync9, readdirSync, readFileSync as readFileSync7 } from "fs";
2303
- import { join as join6, basename } from "path";
2939
+ import { existsSync as existsSync9, readdirSync, readFileSync as readFileSync9 } from "fs";
2940
+ import { join as join7, basename } from "path";
2304
2941
  async function cmdDicts() {
2305
2942
  const dir = dictDir();
2306
2943
  if (!existsSync9(dir)) {
@@ -2308,7 +2945,7 @@ async function cmdDicts() {
2308
2945
  `);
2309
2946
  return;
2310
2947
  }
2311
- const files = readdirSync(dir).filter((f) => f.endsWith(".txt")).sort().map((f) => join6(dir, f));
2948
+ const files = readdirSync(dir).filter((f) => f.endsWith(".txt")).sort().map((f) => join7(dir, f));
2312
2949
  if (!files.length) {
2313
2950
  process.stdout.write(`No dictionaries found.
2314
2951
  `);
@@ -2317,7 +2954,7 @@ async function cmdDicts() {
2317
2954
  process.stdout.write(`Available dictionaries:
2318
2955
  `);
2319
2956
  for (const f of files) {
2320
- const terms = readFileSync7(f, "utf-8").split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
2957
+ const terms = readFileSync9(f, "utf-8").split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
2321
2958
  const stem = basename(f, ".txt");
2322
2959
  process.stdout.write(` ${stem} (${terms.length} terms)
2323
2960
  `);
@@ -2329,228 +2966,25 @@ async function cmdWatch() {
2329
2966
  await watch();
2330
2967
  }
2331
2968
 
2332
- // src/server.ts
2333
- import { appendFileSync as appendFileSync2 } from "fs";
2334
- import { readFileSync as readFileSync8 } from "fs";
2335
- import { Buffer as Buffer3 } from "buffer";
2336
- import { createHash, timingSafeEqual } from "crypto";
2337
- var WEBHOOK_MAX_BYTES = 1024 * 1024;
2338
- function tokensEqual(a, b) {
2339
- if (!a || !b)
2340
- return false;
2341
- const ha = createHash("sha256").update(a).digest();
2342
- const hb = createHash("sha256").update(b).digest();
2343
- return timingSafeEqual(ha, hb);
2344
- }
2345
- async function handleWebhook(payload, transcriptPath) {
2346
- const line = formatTranscriptLine(payload);
2347
- if (line !== null) {
2348
- appendFileSync2(transcriptPath, line + `
2349
- `);
2350
- }
2351
- return line;
2352
- }
2353
- function selectedFrame(latest, bySource, source) {
2354
- const key = normalizeFrameSource(source);
2355
- return key ? bySource.get(key) ?? { raw: null, metadata: null } : latest;
2356
- }
2357
- function frameInventory(bySource) {
2358
- const seen = new Set;
2359
- const frames = [];
2360
- for (const frame of bySource.values()) {
2361
- const sourceKey = frame.metadata?.source_key;
2362
- if (!frame.metadata || !sourceKey || seen.has(sourceKey))
2363
- continue;
2364
- seen.add(sourceKey);
2365
- frames.push(frame.metadata);
2366
- }
2367
- frames.sort((a, b) => String(a.source_key).localeCompare(String(b.source_key)));
2368
- return frames;
2369
- }
2370
- function callIdFromStateFile(path) {
2371
- if (!path)
2372
- return null;
2373
- try {
2374
- const state = JSON.parse(readFileSync8(path, "utf-8"));
2375
- return typeof state.bot_id === "string" ? state.bot_id : null;
2376
- } catch {
2377
- return null;
2378
- }
2379
- }
2380
- function serve(port, transcriptPath, options = {}) {
2381
- const opts = typeof options === "string" || options === null ? { webhookToken: options } : options;
2382
- const latestVideoFrame = { raw: null, metadata: null };
2383
- let presence = newPresenceSnapshot();
2384
- const framesBySource = new Map;
2385
- const frameAuthorized = (req) => tokensEqual(req.headers.get("X-Samograph-Frame-Token"), opts.frameToken);
2386
- const presencePageAuthorized = (req, url) => tokensEqual(req.headers.get("X-Samograph-Presence-Token"), opts.presenceToken) || tokensEqual(url.searchParams.get("token"), opts.presenceToken);
2387
- const presenceJsonAuthorized = (req) => tokensEqual(req.headers.get("X-Samograph-Presence-Token"), opts.presenceToken);
2388
- const presenceWriteAuthorized = (req) => tokensEqual(req.headers.get("X-Samograph-Presence-Token"), opts.presenceWriteToken);
2389
- return Bun.serve({
2390
- port,
2391
- hostname: "127.0.0.1",
2392
- maxRequestBodySize: WEBHOOK_MAX_BYTES,
2393
- async fetch(req, server) {
2394
- const url = new URL(req.url);
2395
- if (req.method === "POST" && url.pathname === "/webhook") {
2396
- if (!tokensEqual(url.searchParams.get("token"), opts.webhookToken)) {
2397
- return Response.json({ error: "forbidden" }, { status: 403 });
2398
- }
2399
- const contentLength = req.headers.get("content-length");
2400
- if (contentLength !== null && Number(contentLength) > WEBHOOK_MAX_BYTES) {
2401
- return Response.json({ error: "payload too large" }, { status: 413 });
2402
- }
2403
- let payload = {};
2404
- try {
2405
- const body = await req.text();
2406
- if (new TextEncoder().encode(body).byteLength > WEBHOOK_MAX_BYTES) {
2407
- return Response.json({ error: "payload too large" }, { status: 413 });
2408
- }
2409
- payload = body ? JSON.parse(body) : {};
2410
- } catch {
2411
- payload = {};
2412
- }
2413
- const transcriptLine = await handleWebhook(payload, transcriptPath);
2414
- if (transcriptLine !== null) {
2415
- const activity = activityFromTranscriptLine(transcriptLine);
2416
- if (activity !== null) {
2417
- presence = appendPresenceActivity(presence, activity);
2418
- }
2419
- }
2420
- return Response.json({ ok: true });
2421
- }
2422
- if (req.method === "GET" && url.pathname === "/frame") {
2423
- if (!frameAuthorized(req)) {
2424
- return new Response("", { status: 403 });
2425
- }
2426
- const frame = selectedFrame(latestVideoFrame, framesBySource, url.searchParams.get("source"));
2427
- if (frame.raw === null) {
2428
- return new Response("", { status: 404 });
2429
- }
2430
- return new Response(frame.raw, {
2431
- headers: { "Content-Type": "image/png" }
2432
- });
2433
- }
2434
- if (req.method === "GET" && url.pathname === "/frame.json") {
2435
- if (!frameAuthorized(req)) {
2436
- return Response.json({ error: "forbidden" }, { status: 403 });
2437
- }
2438
- const frame = selectedFrame(latestVideoFrame, framesBySource, url.searchParams.get("source"));
2439
- if (frame.metadata === null) {
2440
- return Response.json({ error: "no frame" }, { status: 404 });
2441
- }
2442
- return Response.json(frame.metadata);
2443
- }
2444
- if (req.method === "GET" && url.pathname === "/frames.json") {
2445
- if (!frameAuthorized(req)) {
2446
- return Response.json({ error: "forbidden" }, { status: 403 });
2447
- }
2448
- return Response.json({ frames: frameInventory(framesBySource) });
2449
- }
2450
- if (req.method === "GET" && url.pathname === "/presence") {
2451
- if (!presencePageAuthorized(req, url)) {
2452
- return new Response("", { status: 403 });
2453
- }
2454
- return new Response(presencePageHtml(), {
2455
- headers: {
2456
- "Content-Type": "text/html; charset=utf-8",
2457
- "Cache-Control": "no-store"
2458
- }
2459
- });
2460
- }
2461
- if (req.method === "GET" && url.pathname === "/presence.json") {
2462
- if (!presenceJsonAuthorized(req)) {
2463
- return Response.json({ error: "forbidden" }, { status: 403 });
2464
- }
2465
- return Response.json(presence, {
2466
- headers: { "Cache-Control": "no-store" }
2467
- });
2468
- }
2469
- if (req.method === "POST" && url.pathname === "/presence") {
2470
- if (!presenceWriteAuthorized(req)) {
2471
- return Response.json({ error: "forbidden" }, { status: 403 });
2472
- }
2473
- const contentLength = req.headers.get("content-length");
2474
- if (contentLength !== null && Number(contentLength) > WEBHOOK_MAX_BYTES) {
2475
- return Response.json({ error: "payload too large" }, { status: 413 });
2476
- }
2477
- let payload = {};
2478
- try {
2479
- const body = await req.text();
2480
- if (new TextEncoder().encode(body).byteLength > WEBHOOK_MAX_BYTES) {
2481
- return Response.json({ error: "payload too large" }, { status: 413 });
2482
- }
2483
- payload = body ? JSON.parse(body) : {};
2484
- } catch {
2485
- payload = {};
2486
- }
2487
- const rawPayload = payload;
2488
- const state = normalizePresenceState(rawPayload.state);
2489
- if (state === null) {
2490
- return Response.json({ error: "invalid presence state" }, { status: 400 });
2491
- }
2492
- const hasMessage = typeof rawPayload.message === "string" && sanitizePresenceText(rawPayload.message) !== "";
2493
- const message = hasMessage ? sanitizePresenceMessage(rawPayload.message, state) : defaultPresenceMessage(state);
2494
- if (hasMessage) {
2495
- presence = appendPresenceActivity(presence, {
2496
- kind: activityKindForState(state),
2497
- label: labelForPresenceState(state),
2498
- text: message
2499
- });
2500
- }
2501
- presence = newPresenceSnapshot(state, message, presence.activities);
2502
- return Response.json({ ok: true, presence });
2503
- }
2504
- if (url.pathname === "/video-ws") {
2505
- if (!tokensEqual(url.searchParams.get("token"), opts.frameToken)) {
2506
- return new Response("", { status: 403 });
2507
- }
2508
- const upgraded = server.upgrade(req);
2509
- if (upgraded)
2510
- return;
2511
- return new Response("Upgrade Required", { status: 426 });
2512
- }
2513
- return new Response("Not Found", { status: 404 });
2514
- },
2515
- websocket: {
2516
- message(_ws, message) {
2517
- let payload;
2518
- try {
2519
- const text = typeof message === "string" ? message : Buffer3.from(message).toString("utf-8");
2520
- payload = JSON.parse(text);
2521
- } catch {
2522
- return;
2523
- }
2524
- const decoded = decodeVideoSeparatePng(payload, opts.currentCallId?.() ?? null);
2525
- if (decoded === null)
2526
- return;
2527
- latestVideoFrame.raw = decoded.raw;
2528
- latestVideoFrame.metadata = decoded.metadata;
2529
- const frame = { raw: decoded.raw, metadata: decoded.metadata };
2530
- for (const alias of frameSourceAliases(decoded.metadata)) {
2531
- framesBySource.set(alias, frame);
2532
- }
2533
- }
2534
- }
2535
- });
2536
- }
2537
-
2538
2969
  // src/commands/serve.ts
2539
2970
  function resolveServeOptions(args, env = process.env) {
2540
2971
  return {
2541
2972
  webhookToken: args.webhook_token || env.SAMOGRAPH_WEBHOOK_TOKEN || "",
2542
2973
  frameToken: args.frame_token || env.SAMOGRAPH_FRAME_TOKEN || "",
2543
2974
  presenceToken: args.presence_token || env.SAMOGRAPH_PRESENCE_TOKEN || "",
2544
- presenceWriteToken: args.presence_write_token || env.SAMOGRAPH_PRESENCE_WRITE_TOKEN || ""
2975
+ presenceWriteToken: args.presence_write_token || env.SAMOGRAPH_PRESENCE_WRITE_TOKEN || "",
2976
+ publicBase: args.public_base || env.SAMOGRAPH_PUBLIC_BASE || ""
2545
2977
  };
2546
2978
  }
2547
2979
  async function cmdServe(args) {
2548
2980
  const port = args.port || 8080;
2549
2981
  const transcriptPath = args.transcript_file;
2982
+ const { publicBase, ...tokens } = resolveServeOptions(args);
2550
2983
  serve(port, transcriptPath, {
2551
- ...resolveServeOptions(args),
2984
+ ...tokens,
2552
2985
  currentCallId: () => callIdFromStateFile(args.call_id_file)
2553
2986
  });
2987
+ startTunnelWatchdog({ publicBase, transcriptPath });
2554
2988
  await new Promise(() => {});
2555
2989
  }
2556
2990
 
@@ -2622,7 +3056,7 @@ async function cmdDoctor() {
2622
3056
  }
2623
3057
 
2624
3058
  // src/googleDocs.ts
2625
- import { existsSync as existsSync11, readFileSync as readFileSync9 } from "fs";
3059
+ import { existsSync as existsSync11, readFileSync as readFileSync10 } from "fs";
2626
3060
  import { createSign } from "crypto";
2627
3061
  var DOCS_SCOPE = "https://www.googleapis.com/auth/documents";
2628
3062
  var TOKEN_URI = "https://oauth2.googleapis.com/token";
@@ -2650,7 +3084,7 @@ function loadServiceAccountCredentials(path) {
2650
3084
  if (!existsSync11(rawPath)) {
2651
3085
  throw new Error(`Google credentials file not found: ${rawPath}`);
2652
3086
  }
2653
- const parsed = JSON.parse(readFileSync9(rawPath, "utf-8"));
3087
+ const parsed = JSON.parse(readFileSync10(rawPath, "utf-8"));
2654
3088
  if (!parsed.client_email || !parsed.private_key) {
2655
3089
  throw new Error("Google credentials file must contain client_email and private_key");
2656
3090
  }
@@ -2936,14 +3370,16 @@ Put your AI agent in Zoom and Google Meet calls.
2936
3370
  samograph joins through Recall.ai, streams live transcript lines,
2937
3371
  captures call frames on demand, and sends explicit chat messages.
2938
3372
 
2939
- Requires: Bun, RECALL_API_KEY env var (get one at recall.ai), and ngrok (or an alternative tunnel via --webhook-base).
3373
+ Requires: Bun, RECALL_API_KEY env var (get one at recall.ai), and a tunnel:
3374
+ ngrok (default), cloudflared (--tunnel cloudflared), or your own via --webhook-base.
2940
3375
 
2941
3376
  commands:
2942
- join <url> [--name N] [--dict D] [--port P] [--transcript-dir DIR] [--rtmp-url URL] [--rtmp] [--no-ws-video] [--frame-dir DIR] [--webhook-base URL] [--variant web|web_4_core|web_gpu] [--no-presence] [--presence-bg MODE]
3377
+ join <url> [--name N] [--dict D] [--port P] [--transcript-dir DIR] [--rtmp-url URL] [--rtmp] [--no-ws-video] [--frame-dir DIR] [--tunnel ngrok|cloudflared] [--webhook-base URL] [--variant web|web_4_core|web_gpu] [--no-presence] [--presence-bg MODE] [--intro] [--intro-text TEXT]
2943
3378
  leave [bot_id]
2944
3379
  status [bot_id]
2945
3380
  screenshot [--out FILE] [bot_id]
2946
3381
  chat <message> [--bot-id ID]
3382
+ intro [--intro-text TEXT] [--context] [--bot-id ID]
2947
3383
  presence <listening|thinking|speaking|acting|idle> [message]
2948
3384
  transcript [--local] [--file FILE] [--cursor N] [--limit N] [bot_id]
2949
3385
  dicts
@@ -2970,8 +3406,13 @@ options:
2970
3406
  --transcript-dir DIR Directory for timestamped transcript files
2971
3407
  --frame-dir DIR Directory for on-demand frame output
2972
3408
  --no-ws-video Disable WebSocket call-frame capture
2973
- --webhook-base URL Use an existing public tunnel URL instead of starting ngrok
2974
- (e.g. localtunnel/cloudflared pointing at --port)
3409
+ --tunnel NAME Tunnel to start for webhooks: ngrok|cloudflared
3410
+ (default: ngrok; cloudflared quick tunnels are free
3411
+ with no request limits \u2014 recommended when ngrok hits
3412
+ ERR_NGROK_727). Binary from PATH or CLOUDFLARED_BIN.
3413
+ --webhook-base URL Use an existing public tunnel URL instead of starting one
3414
+ (e.g. localtunnel/cloudflared pointing at --port);
3415
+ mutually exclusive with --tunnel
2975
3416
  --variant NAME Recall Output Media bot size: web|web_4_core|web_gpu
2976
3417
  (default: web_4_core; smoothest camera rendering)
2977
3418
  --no-presence Join without the presence camera page (skips the
@@ -2980,6 +3421,11 @@ options:
2980
3421
  (default: robot \u2014 static samoagent avatar image)
2981
3422
  --rtmp Use local RTMP path through ngrok TCP
2982
3423
  --rtmp-url URL Use an existing RTMP endpoint
3424
+ --intro After joining, post a short self-introduction into the
3425
+ meeting chat once the bot is admitted (best-effort).
3426
+ Default text is English (no transcript yet to detect
3427
+ the call language).
3428
+ --intro-text TEXT Custom introduction text for --intro (overrides default)
2983
3429
 
2984
3430
  examples:
2985
3431
  samograph join "https://meet.google.com/abc-defg-hij" --name Leo
@@ -3078,7 +3524,8 @@ function parseArgs(argv) {
3078
3524
  const positionals = [];
3079
3525
  const opts = {};
3080
3526
  const valueFlags = {
3081
- join: new Set(["--name", "--dict", "--port", "--transcript-dir", "--rtmp-url", "--frame-dir", "--webhook-base", "--variant", "--presence-bg"]),
3527
+ join: new Set(["--name", "--dict", "--port", "--transcript-dir", "--rtmp-url", "--frame-dir", "--webhook-base", "--tunnel", "--variant", "--presence-bg", "--intro-text"]),
3528
+ intro: new Set(["--bot-id", "--intro-text"]),
3082
3529
  leave: new Set,
3083
3530
  status: new Set,
3084
3531
  screenshot: new Set(["--out"]),
@@ -3091,10 +3538,11 @@ function parseArgs(argv) {
3091
3538
  frame: new Set(["--out", "--source"]),
3092
3539
  frames: new Set,
3093
3540
  doctor: new Set,
3094
- _serve: new Set(["--port", "--transcript-file", "--webhook-token", "--call-id-file", "--frame-token", "--presence-token", "--presence-write-token"])
3541
+ _serve: new Set(["--port", "--transcript-file", "--webhook-token", "--call-id-file", "--frame-token", "--presence-token", "--presence-write-token", "--public-base"])
3095
3542
  };
3096
3543
  const boolFlags = {
3097
- join: new Set(["--rtmp", "--no-ws-video", "--no-presence"]),
3544
+ join: new Set(["--rtmp", "--no-ws-video", "--no-presence", "--intro"]),
3545
+ intro: new Set(["--context"]),
3098
3546
  leave: new Set,
3099
3547
  status: new Set,
3100
3548
  screenshot: new Set,
@@ -3171,11 +3619,20 @@ function parseArgs(argv) {
3171
3619
  result.rtmp = opts["--rtmp"] === true;
3172
3620
  result.ws_video = opts["--no-ws-video"] !== true;
3173
3621
  result.webhook_base = opts["--webhook-base"] ?? null;
3622
+ result.tunnel = opts["--tunnel"] ?? "ngrok";
3623
+ if (!["ngrok", "cloudflared"].includes(result.tunnel)) {
3624
+ throw new ArgError(`argument --tunnel: invalid choice: '${result.tunnel}' (choose from ngrok, cloudflared)`);
3625
+ }
3626
+ if (opts["--tunnel"] !== undefined && result.webhook_base !== null) {
3627
+ throw new ArgError("argument --tunnel: not allowed with --webhook-base (an external tunnel is already provided)");
3628
+ }
3174
3629
  result.no_presence = opts["--no-presence"] === true;
3175
3630
  result.presence_bg = opts["--presence-bg"] ?? null;
3176
3631
  if (result.presence_bg !== null && !["robot", "sphere", "field", "static", "cycle"].includes(result.presence_bg)) {
3177
3632
  throw new ArgError(`argument --presence-bg: invalid choice: '${result.presence_bg}' (choose from robot, sphere, field, static, cycle)`);
3178
3633
  }
3634
+ result.intro = opts["--intro"] === true;
3635
+ result.intro_text = opts["--intro-text"] ?? null;
3179
3636
  result.frame_dir = opts["--frame-dir"] ?? null;
3180
3637
  result.variant = opts["--variant"] ?? "web_4_core";
3181
3638
  if (result.variant !== null && !["web", "web_4_core", "web_gpu"].includes(result.variant)) {
@@ -3227,6 +3684,12 @@ function parseArgs(argv) {
3227
3684
  result.bot_id = opts["--bot-id"] ?? null;
3228
3685
  break;
3229
3686
  }
3687
+ case "intro": {
3688
+ result.bot_id = opts["--bot-id"] ?? null;
3689
+ result.intro_text = opts["--intro-text"] ?? null;
3690
+ result.context = opts["--context"] === true;
3691
+ break;
3692
+ }
3230
3693
  case "presence": {
3231
3694
  if (positionals.length < 1) {
3232
3695
  throw new ArgError("the following arguments are required: state");
@@ -3273,6 +3736,7 @@ function parseArgs(argv) {
3273
3736
  result.transcript_file = opts["--transcript-file"];
3274
3737
  result.webhook_token = opts["--webhook-token"] ?? "";
3275
3738
  result.call_id_file = opts["--call-id-file"] ?? "";
3739
+ result.public_base = opts["--public-base"] ?? "";
3276
3740
  result.frame_token = opts["--frame-token"] ?? "";
3277
3741
  result.presence_token = opts["--presence-token"] ?? "";
3278
3742
  result.presence_write_token = opts["--presence-write-token"] ?? "";
@@ -3295,6 +3759,8 @@ async function dispatch(args) {
3295
3759
  return cmdTranscript(args);
3296
3760
  case "chat":
3297
3761
  return cmdChat(args);
3762
+ case "intro":
3763
+ return cmdIntro(args);
3298
3764
  case "presence":
3299
3765
  return cmdPresence(args);
3300
3766
  case "frame":