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 +81 -8
- package/dist/recall-client.js +21 -3
- package/package.json +1 -1
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
|
-
//
|
|
4623
|
-
//
|
|
4624
|
-
//
|
|
4625
|
-
//
|
|
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
|
-
//
|
|
4628
|
-
//
|
|
4629
|
-
//
|
|
4630
|
-
|
|
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);
|
package/dist/recall-client.js
CHANGED
|
@@ -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
|
-
//
|
|
154
|
-
|
|
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
|
-
|
|
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);
|