osborn 0.9.85 → 0.9.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -201,6 +201,22 @@ let leaveRoomHook = null;
201
201
  // as the existing fetch-log/save-log flow so we don't ship Supabase credentials
202
202
  // to the Fly machine.
203
203
  let bugReportHook = null;
204
+ const canvasClients = new Set();
205
+ let latestCanvasShow = null; // last 'show' so a reconnecting canvas resyncs its visual
206
+ function pushCanvas(evt) {
207
+ if (evt.kind === 'show')
208
+ latestCanvasShow = evt;
209
+ const line = `data: ${JSON.stringify(evt)}\n\n`;
210
+ for (const res of canvasClients) {
211
+ try {
212
+ res.write(line);
213
+ }
214
+ catch {
215
+ canvasClients.delete(res);
216
+ }
217
+ }
218
+ console.log(`🖼️ canvas ${evt.kind}: ${evt.kind === 'say' ? evt.text.slice(0, 60) : evt.mode} → ${canvasClients.size} client(s)`);
219
+ }
204
220
  function startApiServer(workingDir, port) {
205
221
  const server = createServer(async (req, res) => {
206
222
  // CORS headers for cloud frontend
@@ -303,6 +319,12 @@ function startApiServer(workingDir, port) {
303
319
  req.on('end', () => {
304
320
  try {
305
321
  const payload = JSON.parse(body);
322
+ // 0.9.86: log every receipt so we can SEE what Recall streams
323
+ // (event type + word count) — the handler was silent, making it
324
+ // impossible to tell whether webhooks arrive or are just filtered.
325
+ const evt = payload?.event ?? 'unknown';
326
+ const wc = (payload?.data?.data?.words ?? []).length;
327
+ console.log(`📨 Recall webhook: event=${evt} words=${wc}`);
306
328
  const recall = getRecallClient();
307
329
  if (recall)
308
330
  recall.handleWebhook(payload);
@@ -470,6 +492,50 @@ function startApiServer(workingDir, port) {
470
492
  console.log('[events] SSE client connected');
471
493
  return;
472
494
  }
495
+ // ── Meeting canvas: SSE stream the Recall webpage subscribes to ──────────
496
+ // The canvas page (frontend /meeting-canvas) opens this as an EventSource.
497
+ // Recall renders that page as the bot's camera+mic, so whatever we push here
498
+ // becomes what the meeting sees + hears. On connect we resync the current
499
+ // visual so a page reload doesn't blank the bot's camera.
500
+ if (req.method === 'GET' && url.pathname === '/canvas-stream') {
501
+ res.writeHead(200, {
502
+ 'Content-Type': 'text/event-stream',
503
+ 'Cache-Control': 'no-cache',
504
+ 'Connection': 'keep-alive',
505
+ 'X-Accel-Buffering': 'no',
506
+ });
507
+ res.write(`: canvas connected ${new Date().toISOString()}\n\n`);
508
+ res.write(`data: ${JSON.stringify(latestCanvasShow ?? { kind: 'show', mode: 'idle' })}\n\n`);
509
+ canvasClients.add(res);
510
+ const hb = setInterval(() => { try {
511
+ res.write(`: ping ${Date.now()}\n\n`);
512
+ }
513
+ catch { } }, 10_000);
514
+ req.on('close', () => { clearInterval(hb); canvasClients.delete(res); console.log('[canvas] client disconnected'); });
515
+ console.log(`[canvas] client connected (${canvasClients.size} total)`);
516
+ return;
517
+ }
518
+ // ── Meeting canvas: control endpoint (director / agent tool) ─────────────
519
+ // POST /canvas { kind:'say', text } | { kind:'show', mode, title?, items?, url?, text? }
520
+ if (req.method === 'POST' && url.pathname === '/canvas') {
521
+ let body = '';
522
+ req.on('data', (c) => { body += c; });
523
+ req.on('end', () => {
524
+ try {
525
+ const evt = JSON.parse(body || '{}');
526
+ if (evt.kind !== 'say' && evt.kind !== 'show')
527
+ throw new Error("kind must be 'say' or 'show'");
528
+ pushCanvas(evt);
529
+ res.writeHead(200, { 'Content-Type': 'application/json' });
530
+ res.end(JSON.stringify({ ok: true, clients: canvasClients.size }));
531
+ }
532
+ catch (e) {
533
+ res.writeHead(400, { 'Content-Type': 'application/json' });
534
+ res.end(JSON.stringify({ error: e.message }));
535
+ }
536
+ });
537
+ return;
538
+ }
473
539
  // GET /sessions/export — stream a gzipped tar of ~/.claude/projects/ AND
474
540
  // ~/.claude/skills/ to the client. Both directories ship in one archive so
475
541
  // a sync covers conversations (projects/) and learned skills together —
@@ -4619,15 +4685,22 @@ async function main() {
4619
4685
  (process.env.FLY_APP_NAME
4620
4686
  ? `https://${process.env.FLY_APP_NAME}.fly.dev`
4621
4687
  : `http://localhost:${apiPort}`);
4622
- // Polling architecture (post-2026-05-22): the bot joins by name
4623
- // only — no output_media webpage, no LiveKit republish, no audio
4624
- // pipeline at all. Recall captures the meeting audio internally
4625
- // and we pull the transcript via its REST API every ~30s.
4688
+ // Transcript: Recall captures meeting audio internally; we get it
4689
+ // live via the realtime webhook (+ REST poll backstop). Output:
4690
+ // the bot casts the meeting canvas as its camera+mic (below), so
4691
+ // it can show visuals and speak into the meeting on demand.
4626
4692
  await sendToFrontend({ type: 'meeting_joining', message: 'Osborn is joining your meeting...' });
4627
- // 0.9.85: optional castUrl — a webpage to display as the bot's
4628
- // camera (live feed / seeded site). Passed from the frontend or
4629
- // env OSBORN_MEETING_CAST_URL for testing.
4630
- const castUrl = data.castUrl || process.env.OSBORN_MEETING_CAST_URL || undefined;
4693
+ // Cast target = the bot's camera+mic webpage (Recall output_media).
4694
+ // DEFAULT is the meeting canvas (frontend /meeting-canvas), pointed
4695
+ // at THIS agent's public URL so it can subscribe to /canvas-stream
4696
+ // and become the bot's face (visuals) + voice (TTS it speaks). An
4697
+ // explicit castUrl from the frontend or OSBORN_MEETING_CAST_URL
4698
+ // overrides it (e.g. to cast a live feed or a seeded site instead).
4699
+ const frontendUrl = (process.env.OSBORN_FRONTEND_URL || 'https://www.voice-native.com').replace(/\/$/, '');
4700
+ const canvasUrl = /^https:\/\//.test(webhookBase)
4701
+ ? `${frontendUrl}/meeting-canvas?agent=${encodeURIComponent(webhookBase)}`
4702
+ : undefined;
4703
+ const castUrl = data.castUrl || process.env.OSBORN_MEETING_CAST_URL || canvasUrl;
4631
4704
  const botId = await recallJoin.joinMeeting(meetingUrl, webhookBase, { castUrl });
4632
4705
  const sessionId = currentLLM?.sessionId || currentResumeSessionId || 'default';
4633
4706
  recallJoin.registerBot(botId, sessionId);
@@ -149,9 +149,19 @@ export class RecallClient extends EventEmitter {
149
149
  const bot = (await res.json());
150
150
  return bot.status_changes?.at(-1)?.code ?? 'unknown';
151
151
  }
152
+ // 0.9.86: track the last text we emitted per speaker so partials don't
153
+ // spam the LLM with every incremental word — only emit when the utterance
154
+ // grows meaningfully or finalizes.
155
+ #lastEmitted = new Map();
152
156
  handleWebhook(payload) {
153
- // Only process final transcripts (transcript.data), skip partials
154
- if (payload.event !== 'transcript.data')
157
+ // Accept BOTH finals (transcript.data) AND partials (transcript.partial_data).
158
+ // recallai_streaming in prioritize_low_latency mode emits partials DURING
159
+ // the call and finals lag — ignoring partials (the old behavior) meant no
160
+ // live transcript at all (confirmed 2026-07-29: 0 webhook-processed turns
161
+ // mid-call). We emit partials for liveness, deduped against the last text.
162
+ const isFinal = payload.event === 'transcript.data';
163
+ const isPartial = payload.event === 'transcript.partial_data';
164
+ if (!isFinal && !isPartial)
155
165
  return;
156
166
  const words = payload.data?.data?.words ?? [];
157
167
  const text = words.map(w => w.text).join(' ').trim();
@@ -159,7 +169,15 @@ export class RecallClient extends EventEmitter {
159
169
  return;
160
170
  const speaker = payload.data?.data?.participant?.name ?? 'Unknown';
161
171
  const botId = payload.data?.bot?.id ?? 'unknown';
162
- this.emit('transcript', { botId, speaker, text });
172
+ const key = `${botId}:${speaker}`;
173
+ // Dedup: skip a partial that just repeats/prefixes what we already sent.
174
+ const prev = this.#lastEmitted.get(key) ?? '';
175
+ if (!isFinal && (text === prev || (prev && text.startsWith(prev) && text.length - prev.length < 8)))
176
+ return;
177
+ this.#lastEmitted.set(key, text);
178
+ if (isFinal)
179
+ this.#lastEmitted.delete(key); // reset for the next utterance
180
+ this.emit('transcript', { botId, speaker, text, partial: !isFinal });
163
181
  }
164
182
  registerBot(botId, sessionId) {
165
183
  this.#activeBots.set(botId, sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.85",
3
+ "version": "0.9.87",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {