bloby-bot 0.48.1 → 0.48.3

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.
@@ -288,6 +288,17 @@ export async function startSupervisor() {
288
288
  let currentStreamConvId: string | null = null;
289
289
  let currentStreamBuffer = '';
290
290
 
291
+ // Workspace channel: SSE subscribers receive every chat event the dashboard widget WS
292
+ // clients receive. A subscriber is just a held-open HTTP response we write `data: ...\n\n`
293
+ // chunks to. Registered by `GET /api/agent/chat/stream`, used by `broadcastBloby` below.
294
+ interface ChatSubscriber {
295
+ id: string;
296
+ clientId?: string;
297
+ send: (type: string, data: any) => void;
298
+ close: () => void;
299
+ }
300
+ const chatSubscribers = new Set<ChatSubscriber>();
301
+
291
302
  /** Call worker API endpoints (in-process via supervisor's own HTTP server) */
292
303
  async function workerApi(apiPath: string, method = 'GET', body?: any) {
293
304
  const opts: RequestInit = { method, headers: { 'Content-Type': 'application/json', 'x-internal': internalSecret } };
@@ -296,12 +307,16 @@ export async function startSupervisor() {
296
307
  return res.json();
297
308
  }
298
309
 
299
- /** Broadcast to all bloby WS clients EXCEPT sender */
310
+ /** Broadcast to all bloby chat surfaces EXCEPT the sender WS. Workspace SSE subscribers
311
+ * always receive (sender is a WS, so no SSE collision). */
300
312
  function broadcastBlobyExcept(sender: WebSocket, type: string, data: any) {
301
313
  const msg = JSON.stringify({ type, data });
302
314
  for (const client of blobyWss.clients) {
303
315
  if (client !== sender && client.readyState === WebSocket.OPEN) client.send(msg);
304
316
  }
317
+ for (const sub of chatSubscribers) {
318
+ try { sub.send(type, data); } catch {}
319
+ }
305
320
  }
306
321
 
307
322
  // ── Auth middleware ──
@@ -426,7 +441,21 @@ export async function startSupervisor() {
426
441
  const proxy = http.request(
427
442
  { host: '127.0.0.1', port: backendPort, path: backendPath, method: req.method, headers: req.headers },
428
443
  (proxyRes) => {
444
+ const ct = String(proxyRes.headers['content-type'] || '');
445
+ const isSse = ct.includes('text/event-stream');
446
+ if (isSse) {
447
+ console.log(`[app-proxy] SSE upstream status=${proxyRes.statusCode} ct="${ct}" url=${backendPath}`);
448
+ }
429
449
  res.writeHead(proxyRes.statusCode!, proxyRes.headers);
450
+ if (isSse) {
451
+ try { res.socket?.setNoDelay(true); } catch {}
452
+ proxyRes.on('data', (chunk: Buffer) => {
453
+ console.log(`[app-proxy] SSE chunk bytes=${chunk.length} preview=${JSON.stringify(chunk.toString('utf-8').slice(0, 80))}`);
454
+ });
455
+ proxyRes.on('end', () => console.log(`[app-proxy] SSE upstream END`));
456
+ proxyRes.on('error', (e: any) => console.log(`[app-proxy] SSE upstream ERROR ${e.message}`));
457
+ res.on('close', () => console.log(`[app-proxy] SSE res CLOSE`));
458
+ }
430
459
  proxyRes.pipe(res);
431
460
  },
432
461
  );
@@ -836,15 +865,16 @@ ${!connected ? `<script>
836
865
  return;
837
866
  }
838
867
 
839
- const { text, alexaUserId, sessionId, kind } = JSON.parse(body || '{}') as {
840
- text?: string; alexaUserId?: string; sessionId?: string; kind?: string;
868
+ const { text, alexaUserId, sessionId, deviceId, locale, kind, apiEndpoint, apiAccessToken, requestId } = JSON.parse(body || '{}') as {
869
+ text?: string; alexaUserId?: string; sessionId?: string; deviceId?: string; locale?: string; kind?: string;
870
+ apiEndpoint?: string; apiAccessToken?: string; requestId?: string;
841
871
  };
842
872
 
843
873
  // Launch / Stop / Help intents don't need to hit the agent — the relay
844
874
  // can short-circuit, but we accept them here too for symmetry.
845
875
  if (kind === 'launch') {
846
876
  res.writeHead(200);
847
- res.end(JSON.stringify({ ok: true, reply: 'Bloby here, what can I help with?', endSession: false }));
877
+ res.end(JSON.stringify({ ok: true, reply: 'Morphy here, what can I help with?', endSession: false }));
848
878
  return;
849
879
  }
850
880
  if (kind === 'stop' || kind === 'cancel') {
@@ -860,18 +890,24 @@ ${!connected ? `<script>
860
890
  }
861
891
 
862
892
  try {
863
- const reply = await channelManager.handleAlexaInbound({ text, alexaUserId, alexaSessionId: sessionId });
893
+ const reply = await channelManager.handleAlexaInbound({
894
+ text, alexaUserId, alexaSessionId: sessionId, deviceId, locale,
895
+ apiEndpoint, apiAccessToken, requestId,
896
+ });
864
897
  res.writeHead(200);
865
898
  res.end(JSON.stringify({ ok: true, reply, endSession: false }));
866
899
  } catch (err: any) {
867
900
  // Timeout or aborted turn — agent will still finish and the result lands
868
- // in the shared conversation. Tell Alexa we'll get back to them.
869
- const overflow = cfg.channels?.alexa?.overflow?.mode || 'chat-only';
870
- const fallback = overflow === 'ha-announce'
871
- ? "I'll let you know in a moment."
872
- : "I'll reply in your chat when ready.";
901
+ // in the shared conversation. The agent is responsible for any
902
+ // out-of-band notification (HA announce, chat, WhatsApp) — that's
903
+ // a skill concern, not a supervisor concern.
873
904
  res.writeHead(200);
874
- res.end(JSON.stringify({ ok: true, reply: fallback, endSession: false, overflow: true }));
905
+ res.end(JSON.stringify({
906
+ ok: true,
907
+ reply: "I'll reply in your chat when ready.",
908
+ endSession: false,
909
+ overflow: true,
910
+ }));
875
911
  }
876
912
  } catch (err: any) {
877
913
  res.writeHead(500);
@@ -893,8 +929,9 @@ ${!connected ? `<script>
893
929
  res.end(JSON.stringify({ ok: false, error: 'no-relay-token' }));
894
930
  return;
895
931
  }
896
- const relayBase = cfg.relay.url || 'https://api.bloby.bot';
897
- const resp = await fetch(`${relayBase.replace(/\/$/, '')}/api/alexa/pair`, {
932
+ // The relay API always lives at api.bloby.bot — `cfg.relay.url` is the
933
+ // bot's public profile URL (e.g. https://bloby.bot/<handle>), not the API.
934
+ const resp = await fetch('https://api.bloby.bot/api/alexa/pair', {
898
935
  method: 'POST',
899
936
  headers: {
900
937
  'Content-Type': 'application/json',
@@ -935,37 +972,141 @@ ${!connected ? `<script>
935
972
  // The chat surface auto-converts URLs ending in /pair-page into a button (see MessageBubble).
936
973
  if (req.method === 'GET' && channelPath === '/api/channels/alexa/pair-page') {
937
974
  res.setHeader('Content-Type', 'text/html');
975
+ const alexaStatus = channelManager.getStatus('alexa');
976
+ const alreadyLinked = !!(alexaStatus?.info as any)?.linked;
977
+ const confettiHTML = Array.from({ length: 30 }, (_, i) => {
978
+ const colors = ['#04D1FE', '#AF27E3', '#FB4072', '#4ade80', '#facc15', '#818cf8'];
979
+ const color = colors[Math.floor(Math.random() * colors.length)];
980
+ const left = Math.random() * 100;
981
+ const delay = i * 0.04;
982
+ const drift = (Math.random() - 0.5) * 120;
983
+ const duration = 1.8 + Math.random() * 0.8;
984
+ return `<div class="confetti-dot" style="left:${left}%;background:${color};animation-delay:${delay}s;animation-duration:${duration}s;--drift:${drift}px"></div>`;
985
+ }).join('');
938
986
  res.writeHead(200);
939
- res.end(`<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Connect Alexa | Bloby</title>
987
+ res.end(`<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Connect Alexa</title>
988
+ <link rel="preconnect" href="https://fonts.googleapis.com">
989
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
990
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@600;700&display=swap" rel="stylesheet">
940
991
  <style>
941
- *{margin:0;padding:0;box-sizing:border-box}
942
- body{font-family:'Inter',system-ui,sans-serif;background:#0a0a0b;color:#e4e4e7;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:1.5rem}
943
- .c{text-align:center;max-width:460px}
944
- h1{font-size:1.5rem;font-weight:700;margin-bottom:.5rem}
945
- p{color:#a1a1aa;line-height:1.6;margin-bottom:1rem;font-size:.95rem}
946
- .code{font-family:'JetBrains Mono','SF Mono',monospace;font-size:3rem;font-weight:700;letter-spacing:.5rem;color:#fff;background:#18181b;border:1px solid #27272a;border-radius:1rem;padding:1.5rem;margin:1.5rem 0;display:inline-block;min-width:280px}
947
- .countdown{font-size:.85rem;color:#71717a;margin-top:.5rem}
948
- .btn{display:inline-flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#04D1FE,#AF27E3,#FB4072);border:none;border-radius:9999px;padding:.55rem 1.4rem;color:#fff;font-size:.875rem;font-weight:500;cursor:pointer;height:2.25rem;margin-top:.6rem}
949
- .btn:hover{opacity:.9}
950
- .hint{color:#52525b;font-size:.8rem;margin-top:1rem;font-style:italic}
951
- .err{color:#ef4444;margin-top:1rem}
952
- </style>
953
- </head><body><div class="c">
954
- <h1>Connect Alexa</h1>
955
- <p>Open the Alexa app on your phone and enable the <strong>Bloby</strong> skill. Then say:</p>
956
- <div class="code" id="code">— — — — — —</div>
957
- <p style="font-size:1rem;color:#e4e4e7">"Alexa, ask Bloby to link with code <span id="codeSpoken">———</span>"</p>
958
- <div class="countdown" id="cd"></div>
959
- <button class="btn" onclick="mint()">New code</button>
960
- <p class="hint">Once linked, you can say "Alexa, ask Bloby anything." Codes expire after 10 minutes.</p>
961
- <p class="err" id="err"></p>
992
+ *{margin:0;padding:0;box-sizing:border-box}
993
+ body{background:#212121;color:#f5f5f5;font-family:'Inter',system-ui,-apple-system,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100dvh;margin:0;overflow-x:hidden}
994
+ .container{display:flex;flex-direction:column;align-items:center;max-width:380px;width:100%;padding:20px}
995
+
996
+ .card{background:#2a2a2a;border:1px solid rgba(255,255,255,0.08);border-radius:20px;padding:28px 24px;width:100%;box-shadow:0 0 0 1px rgba(175,39,227,0.1),0 0 20px -5px rgba(175,39,227,0.15);animation:fade-up .5s ease-out both;text-align:center}
997
+
998
+ .header{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:18px}
999
+ .badge-alexa{display:inline-flex;align-items:center;gap:6px;background:#1a1a1a;border:1px solid rgba(255,255,255,0.08);border-radius:9999px;padding:4px 10px;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:0.6px}
1000
+ .badge-alexa::before{content:'';width:8px;height:8px;border-radius:50%;background:linear-gradient(135deg,#04D1FE,#AF27E3);box-shadow:0 0 8px rgba(4,209,254,0.5)}
1001
+ .title{font-family:'Space Grotesk',sans-serif;font-size:22px;font-weight:700;background:linear-gradient(135deg,#04D1FE,#AF27E3,#FB4072);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin-top:6px}
1002
+ .sub{font-size:13px;color:#999;line-height:1.6;margin-top:6px}
1003
+
1004
+ .code-block{margin:18px 0 6px;animation:fade-up .5s ease-out .15s both}
1005
+ .code-label{font-size:11px;color:#666;text-transform:uppercase;letter-spacing:0.8px;margin-bottom:8px}
1006
+ .code-value{font-family:'Space Grotesk',monospace;font-size:36px;font-weight:700;letter-spacing:8px;background:linear-gradient(135deg,#04D1FE,#AF27E3);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;line-height:1.1}
1007
+ .countdown{font-size:12px;color:#666;margin-top:10px;display:inline-flex;align-items:center;gap:6px}
1008
+ .countdown.warn{color:#FB4072}
1009
+ .countdown .dot{width:6px;height:6px;border-radius:50%;background:currentColor;animation:pulse 1.6s ease-in-out infinite;opacity:.6}
1010
+
1011
+ .quote-card{background:#1a1a1a;border:1px solid rgba(255,255,255,0.06);border-radius:12px;padding:14px 16px;margin:18px 0 6px;animation:fade-up .5s ease-out .25s both}
1012
+ .quote-label{font-size:11px;color:#666;text-transform:uppercase;letter-spacing:0.6px;margin-bottom:6px}
1013
+ .quote{font-size:15px;color:#f5f5f5;line-height:1.5;font-style:italic}
1014
+ .quote .invocation{color:#04D1FE;font-style:normal;font-weight:600}
1015
+ .quote .num{background:linear-gradient(135deg,#04D1FE,#AF27E3);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;font-style:normal;font-weight:700;letter-spacing:2px}
1016
+
1017
+ .steps{text-align:left;margin-top:20px;animation:fade-up .5s ease-out .35s both}
1018
+ .steps-title{font-size:11px;color:#666;text-transform:uppercase;letter-spacing:0.6px;margin-bottom:10px;text-align:center}
1019
+ .step{display:flex;gap:12px;font-size:13px;color:#bbb;line-height:1.5;padding:6px 0}
1020
+ .step-num{flex-shrink:0;width:22px;height:22px;border-radius:50%;background:#1a1a1a;border:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;color:#888}
1021
+ .step b{color:#f5f5f5;font-weight:600}
1022
+
1023
+ .btn-row{display:flex;gap:10px;margin-top:20px;width:100%;animation:fade-up .5s ease-out .45s both}
1024
+ .btn{flex:1;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:10px;padding:11px 16px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .2s}
1025
+ .btn-primary{background:linear-gradient(135deg,#AF27E3,#FB4072);color:#fff}
1026
+ .btn-primary:hover{opacity:.9}
1027
+ .btn-primary:disabled{opacity:.5;cursor:not-allowed}
1028
+ .btn-ghost{background:#1a1a1a;border:1px solid rgba(255,255,255,0.1);color:#999}
1029
+ .btn-ghost:hover{border-color:rgba(175,39,227,0.4);color:#f5f5f5}
1030
+
1031
+ .err{color:#FB4072;font-size:13px;margin-top:14px;min-height:1.2em}
1032
+
1033
+ .confetti-wrap{position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:hidden;z-index:0}
1034
+ .confetti-dot{position:absolute;width:8px;height:8px;border-radius:50%;top:-10px;animation:confetti-fall 2s ease-out forwards}
1035
+ @keyframes confetti-fall{0%{opacity:1;transform:translateY(0) translateX(0) rotate(0) scale(1)}100%{opacity:0;transform:translateY(100vh) translateX(var(--drift)) rotate(360deg) scale(.5)}}
1036
+
1037
+ .video-wrap{margin-bottom:8px;animation:pop-in .5s cubic-bezier(.34,1.56,.64,1) forwards}
1038
+ .video-wrap video{width:200px;object-fit:contain;pointer-events:none}
1039
+ @keyframes pop-in{0%{transform:scale(0);opacity:0}100%{transform:scale(1);opacity:1}}
1040
+
1041
+ .text-wrap{text-align:center;animation:fade-up .5s ease-out .3s both}
1042
+ .success-sub{font-size:14px;color:#999;line-height:1.5;margin-top:6px}
1043
+
1044
+ @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.8)}}
1045
+ @keyframes fade-up{0%{opacity:0;transform:translateY(10px)}100%{opacity:1;transform:translateY(0)}}
1046
+ </style></head><body>
1047
+ <div class="container" id="root">
1048
+ ${alreadyLinked
1049
+ ? `<div class="confetti-wrap">${confettiHTML}</div>
1050
+ <div class="video-wrap"><video autoplay muted playsinline><source src="/bloby_happy_reappearing.mov" type='video/mp4; codecs="hvc1"'><source src="/bloby_happy_reappearing.webm" type="video/webm"></video></div>
1051
+ <div class="text-wrap">
1052
+ <div class="title">Connected!</div>
1053
+ <p class="success-sub">Alexa is linked. Say <b style="color:#f5f5f5">"Alexa, open Morphy Agent"</b> to start a conversation, or <b style="color:#f5f5f5">"Alexa, tell Morphy Agent &lt;command&gt;"</b> for one-shots.</p>
1054
+ <p class="success-sub" style="margin-top:14px;font-size:12px;color:#666">You can close this page.</p>
1055
+ </div>`
1056
+ : `<div class="card" id="pendingCard">
1057
+ <div class="header">
1058
+ <span class="badge-alexa">Amazon Alexa</span>
1059
+ <div class="title">Link your Alexa</div>
1060
+ <p class="sub">Open the Alexa app and enable the <b style="color:#f5f5f5">Morphy Agent</b> skill, then say the phrase below.</p>
1061
+ </div>
1062
+
1063
+ <div class="code-block">
1064
+ <div class="code-label">Pairing code</div>
1065
+ <div class="code-value" id="code">— — — — — —</div>
1066
+ <div class="countdown" id="cd"><span class="dot"></span><span id="cdText">Generating code…</span></div>
1067
+ </div>
1068
+
1069
+ <div class="quote-card">
1070
+ <div class="quote-label">Say to Alexa</div>
1071
+ <div class="quote">"<span class="invocation">Alexa, ask Morphy Agent to link with code</span> <span class="num" id="codeSpoken">———</span>"</div>
1072
+ </div>
1073
+
1074
+ <div class="steps">
1075
+ <div class="steps-title">How to link</div>
1076
+ <div class="step"><div class="step-num">1</div><div>Open the <b>Alexa</b> app on your phone</div></div>
1077
+ <div class="step"><div class="step-num">2</div><div>Find and enable the <b>Morphy Agent</b> skill</div></div>
1078
+ <div class="step"><div class="step-num">3</div><div>Say the phrase above to your Alexa device</div></div>
1079
+ </div>
1080
+
1081
+ <div class="btn-row">
1082
+ <button class="btn btn-ghost" onclick="mint()">New code</button>
1083
+ </div>
1084
+ <p class="err" id="err"></p>
1085
+ </div>`}
962
1086
  </div>
963
1087
  <script>
1088
+ ${alreadyLinked ? `` : `
964
1089
  let expiresAt=0,timer=null;
1090
+ function fmt(n){return String(n).padStart(2,'0')}
1091
+ function tick(){
1092
+ const s=Math.max(0,Math.round((expiresAt-Date.now())/1000));
1093
+ const cd=document.getElementById('cd');
1094
+ const txt=document.getElementById('cdText');
1095
+ if(s>0){
1096
+ txt.textContent='Expires in '+Math.floor(s/60)+':'+fmt(s%60);
1097
+ cd.classList.toggle('warn',s<60);
1098
+ }else{
1099
+ txt.textContent='Expired — generate a new code';
1100
+ cd.classList.add('warn');
1101
+ if(timer){clearInterval(timer);timer=null}
1102
+ }
1103
+ }
965
1104
  async function mint(){
966
1105
  document.getElementById('err').textContent='';
967
1106
  document.getElementById('code').textContent='— — — — — —';
968
1107
  document.getElementById('codeSpoken').textContent='———';
1108
+ document.getElementById('cdText').textContent='Generating code…';
1109
+ document.getElementById('cd').classList.remove('warn');
969
1110
  try{
970
1111
  const r=await fetch('/api/channels/alexa/pair',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
971
1112
  const d=await r.json();
@@ -977,12 +1118,8 @@ async function mint(){
977
1118
  timer=setInterval(tick,1000);tick();
978
1119
  }catch(e){document.getElementById('err').textContent=e.message}
979
1120
  }
980
- function tick(){
981
- const s=Math.max(0,Math.round((expiresAt-Date.now())/1000));
982
- document.getElementById('cd').textContent=s>0?('Expires in '+Math.floor(s/60)+':'+String(s%60).padStart(2,'0')):'Expired — generate a new code';
983
- if(s<=0&&timer){clearInterval(timer);timer=null}
984
- }
985
1121
  mint();
1122
+ `}
986
1123
  </script>
987
1124
  </body></html>`);
988
1125
  return;
@@ -1052,27 +1189,43 @@ mint();
1052
1189
  }
1053
1190
 
1054
1191
  // ── Agent API — SDK gateway for workspace code ──
1055
- if (req.method === 'POST' && req.url?.startsWith('/api/agent/')) {
1192
+ if (req.url?.startsWith('/api/agent/')) {
1056
1193
  const agentPath = req.url.split('?')[0];
1057
- res.setHeader('Content-Type', 'application/json');
1058
- res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
1059
1194
 
1060
1195
  // Localhost-only guard
1061
1196
  const remoteIp = req.socket.remoteAddress || '';
1062
1197
  if (remoteIp !== '127.0.0.1' && remoteIp !== '::1' && remoteIp !== '::ffff:127.0.0.1') {
1198
+ res.setHeader('Content-Type', 'application/json');
1063
1199
  res.writeHead(403);
1064
1200
  res.end(JSON.stringify({ ok: false, error: 'Agent API is localhost-only.' }));
1065
1201
  return;
1066
1202
  }
1067
1203
 
1068
- // Auth: x-agent-secret header
1069
- if (req.headers['x-agent-secret'] !== agentSecret) {
1204
+ // Auth: x-agent-secret header (query param fallback for EventSource which cannot set headers)
1205
+ const urlObj = new URL(req.url, `http://${req.headers.host}`);
1206
+ const headerSecret = req.headers['x-agent-secret'];
1207
+ const querySecret = urlObj.searchParams.get('secret');
1208
+ if (headerSecret !== agentSecret && querySecret !== agentSecret) {
1209
+ res.setHeader('Content-Type', 'application/json');
1070
1210
  res.writeHead(401);
1071
- res.end(JSON.stringify({ ok: false, error: 'Invalid or missing x-agent-secret header.' }));
1211
+ res.end(JSON.stringify({ ok: false, error: 'Invalid or missing x-agent-secret.' }));
1072
1212
  return;
1073
1213
  }
1074
1214
 
1075
- if (agentPath === '/api/agent/query') {
1215
+ const readJsonBody = () => new Promise<any>((resolve, reject) => {
1216
+ let body = '';
1217
+ req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
1218
+ req.on('end', () => {
1219
+ try { resolve(body ? JSON.parse(body) : {}); }
1220
+ catch (err: any) { reject(err); }
1221
+ });
1222
+ req.on('error', reject);
1223
+ });
1224
+
1225
+ // POST /api/agent/query — one-shot agent query (legacy AGENT-API.md endpoint)
1226
+ if (req.method === 'POST' && agentPath === '/api/agent/query') {
1227
+ res.setHeader('Content-Type', 'application/json');
1228
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
1076
1229
  let body = '';
1077
1230
  req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
1078
1231
  req.on('end', async () => {
@@ -1080,7 +1233,6 @@ mint();
1080
1233
  const parsed = JSON.parse(body) as AgentQueryRequest;
1081
1234
  const result = await handleAgentQuery(parsed);
1082
1235
 
1083
- // If the agent wrote files, restart backend + broadcast HMR
1084
1236
  if (result.usedFileTools) {
1085
1237
  resetBackendRestarts();
1086
1238
  stopBackend().then(() => spawnBackend(backendPort));
@@ -1097,6 +1249,291 @@ mint();
1097
1249
  return;
1098
1250
  }
1099
1251
 
1252
+ // ── Workspace channel: live-conversation mirror of the Bloby chat ──
1253
+ // The workspace is a chat surface like the dashboard widget. Everything users
1254
+ // see in the widget + WhatsApp is mirrored here; messages sent from workspace
1255
+ // run through the SAME orchestrator with the SAME memory/agents/recent history.
1256
+
1257
+ // GET /api/agent/chat/stream — Server-Sent Events, every chat event (bot:*, chat:*)
1258
+ if (req.method === 'GET' && agentPath === '/api/agent/chat/stream') {
1259
+ const clientId = urlObj.searchParams.get('clientId') || undefined;
1260
+ const subId = crypto.randomBytes(8).toString('hex');
1261
+ console.log(`[sse-handler] OPEN sub=${subId} clientId=${clientId} remote=${req.socket.remoteAddress}`);
1262
+
1263
+ res.setHeader('Content-Type', 'text/event-stream');
1264
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
1265
+ res.setHeader('Connection', 'keep-alive');
1266
+ res.setHeader('X-Accel-Buffering', 'no');
1267
+ res.writeHead(200);
1268
+ try { res.socket?.setNoDelay(true); } catch {}
1269
+ const wrote = res.write(': connected\n\n');
1270
+ console.log(`[sse-handler] wrote initial comment sub=${subId} writeOk=${wrote}`);
1271
+
1272
+ const sub: ChatSubscriber = {
1273
+ id: subId,
1274
+ clientId,
1275
+ send: (type, data) => {
1276
+ if (res.writableEnded) {
1277
+ console.log(`[sse-handler] skip write (ended) sub=${subId} type=${type}`);
1278
+ return;
1279
+ }
1280
+ const ok = res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
1281
+ console.log(`[sse-handler] write sub=${subId} type=${type} ok=${ok} bytes=${(type.length + JSON.stringify(data).length + 10)}`);
1282
+ },
1283
+ close: () => { try { res.end(); } catch {} },
1284
+ };
1285
+ chatSubscribers.add(sub);
1286
+
1287
+ if (agentQueryActive && currentStreamConvId) {
1288
+ sub.send('chat:state', {
1289
+ streaming: true,
1290
+ conversationId: currentStreamConvId,
1291
+ buffer: currentStreamBuffer,
1292
+ });
1293
+ }
1294
+
1295
+ const keepAlive = setInterval(() => {
1296
+ if (res.writableEnded) return;
1297
+ res.write(': ping\n\n');
1298
+ console.log(`[sse-handler] ping sub=${subId}`);
1299
+ }, 25_000);
1300
+
1301
+ req.on('close', () => {
1302
+ console.log(`[sse-handler] CLOSE sub=${subId} reason=req-close`);
1303
+ clearInterval(keepAlive);
1304
+ chatSubscribers.delete(sub);
1305
+ });
1306
+ res.on('close', () => {
1307
+ console.log(`[sse-handler] CLOSE sub=${subId} reason=res-close`);
1308
+ });
1309
+ res.on('error', (err: any) => {
1310
+ console.log(`[sse-handler] ERROR sub=${subId} ${err?.message}`);
1311
+ });
1312
+ return;
1313
+ }
1314
+
1315
+ // GET /api/agent/chat/state — snapshot: convId + bot/user names + recent messages
1316
+ if (req.method === 'GET' && agentPath === '/api/agent/chat/state') {
1317
+ res.setHeader('Content-Type', 'application/json');
1318
+ (async () => {
1319
+ try {
1320
+ const ctx = await workerApi('/api/context/current');
1321
+ const convId: string | undefined = ctx.conversationId;
1322
+ const [status, recentRaw] = await Promise.all([
1323
+ workerApi('/api/onboard/status'),
1324
+ convId ? workerApi(`/api/conversations/${convId}/messages/recent?limit=50`) : Promise.resolve([]),
1325
+ ]);
1326
+ const messages = Array.isArray(recentRaw)
1327
+ ? recentRaw
1328
+ .filter((m: any) => m.role === 'user' || m.role === 'assistant')
1329
+ .map((m: any) => ({ role: m.role, content: m.content, timestamp: m.created_at }))
1330
+ : [];
1331
+ res.writeHead(200);
1332
+ res.end(JSON.stringify({
1333
+ ok: true,
1334
+ conversationId: convId || null,
1335
+ agentName: status?.agentName || 'Bloby',
1336
+ userName: status?.userName || 'Human',
1337
+ streaming: agentQueryActive,
1338
+ streamConversationId: currentStreamConvId,
1339
+ streamBuffer: currentStreamBuffer,
1340
+ messages,
1341
+ }));
1342
+ } catch (err: any) {
1343
+ res.writeHead(500);
1344
+ res.end(JSON.stringify({ ok: false, error: err.message }));
1345
+ }
1346
+ })();
1347
+ return;
1348
+ }
1349
+
1350
+ // POST /api/agent/chat/message — push a user message into the shared conversation.
1351
+ // Mirrors the chat-WS `user:message` flow: ensures live conversation, persists, pushes.
1352
+ if (req.method === 'POST' && agentPath === '/api/agent/chat/message') {
1353
+ res.setHeader('Content-Type', 'application/json');
1354
+ (async () => {
1355
+ try {
1356
+ const body = await readJsonBody();
1357
+ const content: string = body.content;
1358
+ const clientId: string | undefined = body.clientId;
1359
+ if (!content || typeof content !== 'string') {
1360
+ res.writeHead(400);
1361
+ res.end(JSON.stringify({ ok: false, error: 'Missing "content".' }));
1362
+ return;
1363
+ }
1364
+
1365
+ const freshConfig = loadConfig();
1366
+ const provider = freshConfig.ai.provider;
1367
+ if (provider !== 'anthropic' && provider !== 'openai' && provider !== 'pi') {
1368
+ res.writeHead(400);
1369
+ res.end(JSON.stringify({ ok: false, error: `Workspace chat requires a harnessed provider (got "${provider}").` }));
1370
+ return;
1371
+ }
1372
+
1373
+ let savedFiles: SavedFile[] = [];
1374
+ if (Array.isArray(body.attachments) && body.attachments.length) {
1375
+ for (const att of body.attachments) {
1376
+ try { savedFiles.push(saveAttachment(att)); }
1377
+ catch (err: any) { log.warn(`[workspace-chat] attachment save: ${err.message}`); }
1378
+ }
1379
+ }
1380
+
1381
+ let convId: string;
1382
+ const ctx = await workerApi('/api/context/current');
1383
+ if (ctx.conversationId) {
1384
+ convId = ctx.conversationId;
1385
+ } else {
1386
+ const conv = await workerApi('/api/conversations', 'POST', { title: content.slice(0, 80), model: freshConfig.ai.model });
1387
+ convId = conv.id;
1388
+ await workerApi('/api/context/set', 'POST', { conversationId: convId });
1389
+ }
1390
+
1391
+ const meta: any = { model: freshConfig.ai.model, channel: 'workspace' };
1392
+ if (savedFiles.length) {
1393
+ meta.attachments = JSON.stringify(savedFiles.map((f) => ({
1394
+ type: f.type, name: f.name, mediaType: f.mediaType, filePath: f.relPath,
1395
+ })));
1396
+ }
1397
+ try {
1398
+ await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
1399
+ role: 'user', content, meta,
1400
+ });
1401
+ } catch (err: any) {
1402
+ log.warn(`[workspace-chat] DB persist error: ${err.message}`);
1403
+ broadcastBloby('chat:persist-error', { conversationId: convId, role: 'user', error: err.message });
1404
+ }
1405
+
1406
+ // Echo user message to every OTHER surface (dashboard WS + other SSE subscribers).
1407
+ // The originating workspace subscriber renders its own message optimistically.
1408
+ broadcastBlobyExceptSubscriber(clientId, 'chat:sync', {
1409
+ conversationId: convId,
1410
+ message: { role: 'user', content, timestamp: new Date().toISOString() },
1411
+ });
1412
+
1413
+ let botName = 'Bloby', humanName = 'Human';
1414
+ let recentMessages: RecentMessage[] = [];
1415
+ try {
1416
+ const [status, recentRaw] = await Promise.all([
1417
+ workerApi('/api/onboard/status'),
1418
+ workerApi(`/api/conversations/${convId}/messages/recent?limit=20`),
1419
+ ]);
1420
+ botName = status.agentName || 'Bloby';
1421
+ humanName = status.userName || 'Human';
1422
+ if (Array.isArray(recentRaw)) {
1423
+ const filtered = recentRaw.filter((m: any) => m.role === 'user' || m.role === 'assistant');
1424
+ if (filtered.length > 0) {
1425
+ recentMessages = filtered.slice(0, -1).map((m: any) => ({
1426
+ role: m.role as 'user' | 'assistant',
1427
+ content: m.content,
1428
+ }));
1429
+ }
1430
+ }
1431
+ } catch {}
1432
+
1433
+ if (!hasConversation(convId)) {
1434
+ log.info(`[workspace-chat] Starting new live conversation: ${convId}`);
1435
+ const waState = channelManager.createWaStreamState();
1436
+ await startConversation(
1437
+ convId,
1438
+ freshConfig.ai.model,
1439
+ createSharedChatOnMessage(convId, freshConfig.ai.model, botName, waState),
1440
+ { botName, humanName },
1441
+ recentMessages,
1442
+ );
1443
+ }
1444
+
1445
+ const agentAttachments = Array.isArray(body.attachments) && body.attachments.length
1446
+ ? body.attachments
1447
+ : undefined;
1448
+
1449
+ // Mirror to WhatsApp self-chat if connected (same behaviour as the widget).
1450
+ const waStatus = channelManager.getStatus('whatsapp');
1451
+ const ownPhone = waStatus?.connected ? (waStatus.info?.phoneNumber as string | undefined) : undefined;
1452
+ const waMirrorTo = ownPhone ? `${ownPhone}@s.whatsapp.net` : undefined;
1453
+
1454
+ channelManager.pushWithRouting(
1455
+ convId,
1456
+ { surface: 'workspace', waSendTo: waMirrorTo, isSelfChat: true },
1457
+ content,
1458
+ agentAttachments,
1459
+ savedFiles,
1460
+ );
1461
+
1462
+ res.writeHead(200);
1463
+ res.end(JSON.stringify({ ok: true, conversationId: convId }));
1464
+ } catch (err: any) {
1465
+ log.warn(`[workspace-chat] message error: ${err.message}`);
1466
+ res.writeHead(500);
1467
+ res.end(JSON.stringify({ ok: false, error: err.message }));
1468
+ }
1469
+ })();
1470
+ return;
1471
+ }
1472
+
1473
+ // POST /api/agent/chat/stop — interrupt the current turn (mirrors user:stop)
1474
+ if (req.method === 'POST' && agentPath === '/api/agent/chat/stop') {
1475
+ res.setHeader('Content-Type', 'application/json');
1476
+ (async () => {
1477
+ try {
1478
+ const ctx = await workerApi('/api/context/current');
1479
+ const convId: string | undefined = ctx.conversationId;
1480
+ if (convId && hasConversation(convId)) {
1481
+ log.info(`[workspace-chat] stop — ending live conversation ${convId}`);
1482
+ endConversation(convId);
1483
+ }
1484
+ res.writeHead(200);
1485
+ res.end(JSON.stringify({ ok: true }));
1486
+ } catch (err: any) {
1487
+ res.writeHead(500);
1488
+ res.end(JSON.stringify({ ok: false, error: err.message }));
1489
+ }
1490
+ })();
1491
+ return;
1492
+ }
1493
+
1494
+ // POST /api/agent/chat/clear — clear context (mirrors user:clear-context)
1495
+ if (req.method === 'POST' && agentPath === '/api/agent/chat/clear') {
1496
+ res.setHeader('Content-Type', 'application/json');
1497
+ (async () => {
1498
+ try {
1499
+ const ctx = await workerApi('/api/context/current');
1500
+ const convId: string | undefined = ctx.conversationId;
1501
+ if (convId && hasConversation(convId)) endConversation(convId);
1502
+ await workerApi('/api/context/clear', 'POST');
1503
+ broadcastBloby('chat:cleared', {});
1504
+ res.writeHead(200);
1505
+ res.end(JSON.stringify({ ok: true }));
1506
+ } catch (err: any) {
1507
+ res.writeHead(500);
1508
+ res.end(JSON.stringify({ ok: false, error: err.message }));
1509
+ }
1510
+ })();
1511
+ return;
1512
+ }
1513
+
1514
+ // POST /api/agent/chat/whisper — transcribe audio via the same Whisper the widget uses
1515
+ if (req.method === 'POST' && agentPath === '/api/agent/chat/whisper') {
1516
+ res.setHeader('Content-Type', 'application/json');
1517
+ (async () => {
1518
+ try {
1519
+ const body = await readJsonBody();
1520
+ if (!body.audio || typeof body.audio !== 'string') {
1521
+ res.writeHead(400);
1522
+ res.end(JSON.stringify({ ok: false, error: 'Missing "audio" (base64).' }));
1523
+ return;
1524
+ }
1525
+ const result = await workerApi('/api/whisper/transcribe', 'POST', { audio: body.audio });
1526
+ res.writeHead(200);
1527
+ res.end(JSON.stringify({ ok: !result.error, ...result }));
1528
+ } catch (err: any) {
1529
+ res.writeHead(500);
1530
+ res.end(JSON.stringify({ ok: false, error: err.message }));
1531
+ }
1532
+ })();
1533
+ return;
1534
+ }
1535
+
1536
+ res.setHeader('Content-Type', 'application/json');
1100
1537
  res.writeHead(404);
1101
1538
  res.end(JSON.stringify({ ok: false, error: 'Unknown agent endpoint.' }));
1102
1539
  return;
@@ -1305,12 +1742,112 @@ mint();
1305
1742
  });
1306
1743
  });
1307
1744
 
1308
- /** Send a message to all connected bloby WS clients */
1745
+ /** Send a message to every chat surface — dashboard widget WS clients AND workspace SSE
1746
+ * subscribers. The workspace mirror sees the exact same event stream the widget does. */
1309
1747
  function broadcastBloby(type: string, data: any = {}) {
1748
+ const msg = JSON.stringify({ type, data });
1749
+ let wsCount = 0;
1750
+ for (const client of blobyWss.clients) {
1751
+ if (client.readyState === WebSocket.OPEN) { client.send(msg); wsCount++; }
1752
+ }
1753
+ let sseCount = 0;
1754
+ for (const sub of chatSubscribers) {
1755
+ try { sub.send(type, data); sseCount++; } catch (e: any) {
1756
+ console.log(`[sse-broadcast] send failed sub=${sub.id}: ${e.message}`);
1757
+ }
1758
+ }
1759
+ if (type.startsWith('bot:') || type.startsWith('chat:')) {
1760
+ console.log(`[sse-broadcast] type=${type} ws=${wsCount} sse=${sseCount} subs=${chatSubscribers.size}`);
1761
+ }
1762
+ }
1763
+
1764
+ /** Like broadcastBloby but skips the workspace SSE subscriber identified by clientId.
1765
+ * Used when the workspace itself originated the event — that subscriber renders the
1766
+ * event optimistically and shouldn't receive its own echo. */
1767
+ function broadcastBlobyExceptSubscriber(excludeClientId: string | undefined, type: string, data: any) {
1310
1768
  const msg = JSON.stringify({ type, data });
1311
1769
  for (const client of blobyWss.clients) {
1312
1770
  if (client.readyState === WebSocket.OPEN) client.send(msg);
1313
1771
  }
1772
+ for (const sub of chatSubscribers) {
1773
+ if (excludeClientId && sub.clientId === excludeClientId) continue;
1774
+ try { sub.send(type, data); } catch {}
1775
+ }
1776
+ }
1777
+
1778
+ /** Build the onMessage handler used by EVERY surface that pushes into the shared live
1779
+ * conversation. Chat-WS, the workspace SSE channel, and (in spirit) the channel manager
1780
+ * all want the same wiring: track stream buffer for late joiners, route streaming text
1781
+ * via the manager's per-conversation routing FIFO, persist responses, defer backend
1782
+ * restarts, and broadcast every event to all chat surfaces.
1783
+ *
1784
+ * Factored so adding a new surface (here: workspace) cannot drift from the chat WS
1785
+ * behaviour — same buffer, same persistence, same restart timing. */
1786
+ function createSharedChatOnMessage(
1787
+ convId: string,
1788
+ model: string,
1789
+ botName: string,
1790
+ waState: ReturnType<typeof channelManager.createWaStreamState>,
1791
+ ) {
1792
+ return async (type: string, eventData: any) => {
1793
+ if (type === 'bot:typing') {
1794
+ currentStreamConvId = convId;
1795
+ currentStreamBuffer = '';
1796
+ agentQueryActive = true;
1797
+ }
1798
+ if (type === 'bot:token' && eventData.token) {
1799
+ currentStreamBuffer += eventData.token;
1800
+ }
1801
+
1802
+ channelManager.routeWaStreamEvent(waState, type, eventData, botName);
1803
+
1804
+ if (type === 'bot:turn-complete') {
1805
+ log.info(`[orchestrator] ──── TURN COMPLETE ────`);
1806
+ log.info(`[orchestrator] File tools used: ${eventData.usedFileTools}`);
1807
+ agentQueryActive = false;
1808
+ currentStreamConvId = null;
1809
+ currentStreamBuffer = '';
1810
+
1811
+ if (eventData.usedFileTools || pendingBackendRestart) {
1812
+ log.info('[orchestrator] Restarting backend (file tools used)');
1813
+ pendingBackendRestart = false;
1814
+ if (backendRestartTimer) { clearTimeout(backendRestartTimer); backendRestartTimer = null; }
1815
+ resetBackendRestarts();
1816
+ stopBackend().then(() => spawnBackend(backendPort));
1817
+ }
1818
+ if (pendingUpdate) {
1819
+ pendingUpdate = false;
1820
+ log.info('[orchestrator] Ending conversation before update...');
1821
+ endConversation(convId);
1822
+ runDeferredUpdate();
1823
+ }
1824
+ broadcastBloby('bot:idle', { conversationId: convId });
1825
+ return;
1826
+ }
1827
+
1828
+ if (type === 'bot:conversation-ended') {
1829
+ log.info(`[orchestrator] Conversation ended: ${convId}`);
1830
+ agentQueryActive = false;
1831
+ currentStreamConvId = null;
1832
+ currentStreamBuffer = '';
1833
+ channelManager.clearRoutes(convId);
1834
+ return;
1835
+ }
1836
+
1837
+ if (type === 'bot:response') {
1838
+ currentStreamBuffer = '';
1839
+ try {
1840
+ await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
1841
+ role: 'assistant', content: eventData.content, meta: { model },
1842
+ });
1843
+ } catch (err: any) {
1844
+ log.warn(`[bloby] DB persist bot response error: ${err.message}`);
1845
+ broadcastBloby('chat:persist-error', { conversationId: convId, role: 'assistant', error: err.message });
1846
+ }
1847
+ }
1848
+
1849
+ broadcastBloby(type, eventData);
1850
+ };
1314
1851
  }
1315
1852
 
1316
1853
  blobyWss.on('connection', (ws) => {
@@ -1629,93 +2166,19 @@ mint();
1629
2166
  log.info(`[orchestrator] Conv: ${convId}`);
1630
2167
  log.info(`[orchestrator] Live conversation exists: ${hasConversation(convId)}`);
1631
2168
 
1632
- // Start a live conversation if one doesn't exist
2169
+ // Start a live conversation if one doesn't exist. The onMessage handler is
2170
+ // shared with the workspace channel — both surfaces push into the same conv
2171
+ // and observe the same events.
1633
2172
  if (!hasConversation(convId)) {
1634
2173
  log.info(`[orchestrator] Starting new live conversation...`);
1635
-
1636
- // Per-conversation WhatsApp streaming state. The manager owns the
1637
- // routing decision: if a WhatsApp inbound message is the trigger
1638
- // for this turn, it sends to that chat; otherwise it falls back to
1639
- // the self-chat mirror (the user's own number).
1640
2174
  const waState = channelManager.createWaStreamState();
1641
-
1642
- await startConversation(convId, freshConfig.ai.model, async (type, eventData) => {
1643
- // Track stream buffer for reconnecting clients
1644
- if (type === 'bot:typing') {
1645
- currentStreamConvId = convId;
1646
- currentStreamBuffer = '';
1647
- agentQueryActive = true;
1648
- }
1649
-
1650
- if (type === 'bot:token' && eventData.token) {
1651
- currentStreamBuffer += eventData.token;
1652
- }
1653
-
1654
- // Route streaming text via the manager's per-conversation routing FIFO.
1655
- // The destination was decided at pushWithRouting time — this is purely a
1656
- // dispatcher and cannot bleed events to the wrong surface.
1657
- channelManager.routeWaStreamEvent(waState, type, eventData, botName);
1658
-
1659
- // Agent finished a turn — handle backend restart + notify client
1660
- if (type === 'bot:turn-complete') {
1661
- log.info(`[orchestrator] ──── TURN COMPLETE ────`);
1662
- log.info(`[orchestrator] File tools used: ${eventData.usedFileTools}`);
1663
- agentQueryActive = false;
1664
- currentStreamConvId = null;
1665
- currentStreamBuffer = '';
1666
-
1667
- if (eventData.usedFileTools || pendingBackendRestart) {
1668
- log.info('[orchestrator] Restarting backend (file tools used)');
1669
- pendingBackendRestart = false;
1670
- if (backendRestartTimer) { clearTimeout(backendRestartTimer); backendRestartTimer = null; }
1671
- resetBackendRestarts();
1672
- stopBackend().then(() => spawnBackend(backendPort));
1673
- }
1674
- if (pendingUpdate) {
1675
- pendingUpdate = false;
1676
- // End the live conversation before updating — SDK child processes
1677
- // must be cleaned up or process.exit() may not terminate cleanly
1678
- log.info('[orchestrator] Ending conversation before update...');
1679
- endConversation(convId);
1680
- runDeferredUpdate();
1681
- }
1682
- // Tell the client the agent is idle — streaming can stop
1683
- broadcastBloby('bot:idle', { conversationId: convId });
1684
- return;
1685
- }
1686
-
1687
- // Conversation ended (query loop exited)
1688
- if (type === 'bot:conversation-ended') {
1689
- log.info(`[orchestrator] Conversation ended: ${convId}`);
1690
- agentQueryActive = false;
1691
- currentStreamConvId = null;
1692
- currentStreamBuffer = '';
1693
- channelManager.clearRoutes(convId);
1694
- return;
1695
- }
1696
-
1697
- // Save assistant response to DB BEFORE broadcasting so a refresh
1698
- // immediately after the bubble appears can't race the INSERT and lose
1699
- // the message. addMessage() in worker/db.ts is self-healing —
1700
- // it INSERT OR IGNOREs the parent conversation row first, so even an
1701
- // orphan convId persists cleanly.
1702
- if (type === 'bot:response') {
1703
- currentStreamBuffer = '';
1704
- try {
1705
- await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
1706
- role: 'assistant', content: eventData.content, meta: { model: freshConfig.ai.model },
1707
- });
1708
- } catch (err: any) {
1709
- log.warn(`[bloby] DB persist bot response error: ${err.message}`);
1710
- // Tell clients the bubble they're about to see is not durable —
1711
- // they can flag/retry rather than silently losing it on refresh.
1712
- broadcastBloby('chat:persist-error', { conversationId: convId, role: 'assistant', error: err.message });
1713
- }
1714
- }
1715
-
1716
- // Stream all events to every connected client
1717
- broadcastBloby(type, eventData);
1718
- }, { botName, humanName }, recentMessages);
2175
+ await startConversation(
2176
+ convId,
2177
+ freshConfig.ai.model,
2178
+ createSharedChatOnMessage(convId, freshConfig.ai.model, botName, waState),
2179
+ { botName, humanName },
2180
+ recentMessages,
2181
+ );
1719
2182
  }
1720
2183
 
1721
2184
  // Push the user message into the live conversation with a pinned routing