osborn 0.9.116 β 0.9.118
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 +90 -58
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -276,6 +276,12 @@ async function synthMp3(text) {
|
|
|
276
276
|
}
|
|
277
277
|
// THE speak path for meetings (2026-08-01): Recall native output_audio FIRST
|
|
278
278
|
// (direct, loud, no canvas capture chain), canvas Web-Audio say as FALLBACK.
|
|
279
|
+
// THE meeting speak path (2026-08-01, live-verified A/B): VOICE via Recall
|
|
280
|
+
// output_audio (reliable, doesn't depend on the headless canvas AudioContext),
|
|
281
|
+
// VISUAL caption pushed to the canvas WITHOUT audio (kind:'caption') so the
|
|
282
|
+
// bot's camera shows what it's saying β no double-audio. output_audio +
|
|
283
|
+
// canvas camera coexist (confirmed: user heard output_audio while the canvas
|
|
284
|
+
// was showing). Falls back to canvas 'say' (audio) only if output_audio fails.
|
|
279
285
|
async function speakIntoMeeting(text) {
|
|
280
286
|
const recall = getRecallClient();
|
|
281
287
|
const botId = recall?.getActiveBotIds?.()[0];
|
|
@@ -283,11 +289,12 @@ async function speakIntoMeeting(text) {
|
|
|
283
289
|
const mp3 = await synthMp3(text);
|
|
284
290
|
if (mp3 && await recall.outputAudio(botId, mp3)) {
|
|
285
291
|
console.log(`π’ spoke via Recall output_audio (${mp3.length}b): "${text.slice(0, 60)}"`);
|
|
292
|
+
pushCanvas({ kind: 'caption', text }); // visual only, no audio
|
|
286
293
|
markMeetingSpeaking(text);
|
|
287
294
|
return;
|
|
288
295
|
}
|
|
289
296
|
}
|
|
290
|
-
console.log(`π½οΈ falling back to canvas say: "${text.slice(0, 50)}"`);
|
|
297
|
+
console.log(`π½οΈ falling back to canvas say (audio): "${text.slice(0, 50)}"`);
|
|
291
298
|
pushCanvas({ kind: 'say', text });
|
|
292
299
|
markMeetingSpeaking(text);
|
|
293
300
|
}
|
|
@@ -715,8 +722,8 @@ function startApiServer(workingDir, port) {
|
|
|
715
722
|
req.on('end', () => {
|
|
716
723
|
try {
|
|
717
724
|
const evt = JSON.parse(body || '{}');
|
|
718
|
-
if (evt.kind !== 'say' && evt.kind !== 'show' && evt.kind !== 'stop')
|
|
719
|
-
throw new Error("kind must be 'say', 'show', or 'stop'");
|
|
725
|
+
if (evt.kind !== 'say' && evt.kind !== 'caption' && evt.kind !== 'show' && evt.kind !== 'stop')
|
|
726
|
+
throw new Error("kind must be 'say', 'caption', 'show', or 'stop'");
|
|
720
727
|
if (evt.kind === 'say') {
|
|
721
728
|
// Native-first speak path (Recall output_audio β canvas fallback).
|
|
722
729
|
void speakIntoMeeting(evt.text);
|
|
@@ -2644,44 +2651,46 @@ async function main() {
|
|
|
2644
2651
|
// Wire up TTS say β bypass LiveKit's BufferedTokenStream, speak directly via session.say()
|
|
2645
2652
|
// Each text block from Claude gets spoken immediately as it arrives, no internal buffering
|
|
2646
2653
|
directLLM.events.on('tts_say', (data) => {
|
|
2647
|
-
// Guard: session must be alive β TTS errors can kill the session while background query runs
|
|
2648
|
-
if (!currentSession) {
|
|
2649
|
-
console.warn(`β οΈ tts_say fired but currentSession is null β text dropped (${data.text?.length || 0} chars): "${data.text || ''}"`);
|
|
2650
|
-
return;
|
|
2651
|
-
}
|
|
2652
2654
|
if (!data.text?.trim()) {
|
|
2653
2655
|
console.log(`π tts_say fired but text is empty β skipping`);
|
|
2654
2656
|
return;
|
|
2655
2657
|
}
|
|
2656
|
-
//
|
|
2657
|
-
//
|
|
2658
|
-
//
|
|
2659
|
-
//
|
|
2658
|
+
// MEETING PATH (fix 2026-08-01) β runs BEFORE the currentSession guard.
|
|
2659
|
+
// A meeting reply routes to the meeting via speakIntoMeeting β Recall
|
|
2660
|
+
// output_audio, which needs NO LiveKit voice session. Keeping this ahead
|
|
2661
|
+
// of the guard is what lets the bot keep replying AFTER the browser voice
|
|
2662
|
+
// session dropped (currentLLM is deliberately kept alive for exactly this;
|
|
2663
|
+
// see handleParticipantDisconnected). Before the fix a participant blip
|
|
2664
|
+
// nulled currentSession and EVERY meeting reply was "text dropped" β the
|
|
2665
|
+
// deaf-bot bug proven live 2026-08-01 via synthetic transcript inject.
|
|
2666
|
+
// Set by PipelineDirectLLM.chat() when the turn is a [MEETING β] chunk.
|
|
2660
2667
|
if (directLLM.suppressMeetingTTS) {
|
|
2661
|
-
//
|
|
2662
|
-
//
|
|
2663
|
-
//
|
|
2664
|
-
|
|
2665
|
-
if (
|
|
2668
|
+
// Parity relay (behind OSBORN_MEETING_AUDIO=parity) speaks via
|
|
2669
|
+
// session.say, so it still needs a live session β it falls through to
|
|
2670
|
+
// the normal browser-TTS path below only when currentSession exists.
|
|
2671
|
+
const parity = process.env.OSBORN_MEETING_AUDIO === 'parity' && meetingCanvasInRoom && activeMeetingBotId && Date.now() < meetingAddressedUntil && !!currentSession;
|
|
2672
|
+
if (parity) {
|
|
2666
2673
|
console.log(`ππΌ meeting reply via NATIVE session.say (canvas relays): "${data.text.slice(0, 50)}"`);
|
|
2667
2674
|
markMeetingSpeaking(data.text);
|
|
2668
|
-
//
|
|
2669
|
-
}
|
|
2670
|
-
else
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
if (activeMeetingBotId && Date.now() < meetingAddressedUntil) {
|
|
2675
|
-
// ONE say per reply (2026-08-01 quality directive): chunking created
|
|
2676
|
-
// seams no scheduler fully hid. A single utterance = a single clean
|
|
2677
|
-
// audio file = zero seams β quality over ~1s of latency. (Structural
|
|
2678
|
-
// fix queued: canvas as LiveKit subscriber playing the agent's real
|
|
2679
|
-
// TTS track β the true "same browser experience" reuse.)
|
|
2675
|
+
// fall through to normal browser TTS (currentSession present)
|
|
2676
|
+
}
|
|
2677
|
+
else if (activeMeetingBotId && Date.now() < meetingAddressedUntil) {
|
|
2678
|
+
// NATIVE sink: one clean output_audio push into the meeting, NO
|
|
2679
|
+
// LiveKit session required β this is the path that survives a
|
|
2680
|
+
// voice-session drop.
|
|
2680
2681
|
console.log(`πβ‘οΈπ’ tts_say β meeting (native audio) t=${new Date().toISOString()}: "${data.text.slice(0, 60)}"`);
|
|
2681
2682
|
void speakIntoMeeting(data.text);
|
|
2682
2683
|
return;
|
|
2683
2684
|
}
|
|
2684
|
-
|
|
2685
|
+
else {
|
|
2686
|
+
console.log(`π tts_say suppressed (meeting turn β response goes to /canvas, not browser): "${data.text.slice(0, 60)}"`);
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
// NORMAL BROWSER TTS (and the parity fall-through): needs a live session.
|
|
2691
|
+
// TTS errors can kill the session while a background query still runs.
|
|
2692
|
+
if (!currentSession) {
|
|
2693
|
+
console.warn(`β οΈ tts_say fired but currentSession is null β text dropped (${data.text?.length || 0} chars): "${data.text || ''}"`);
|
|
2685
2694
|
return;
|
|
2686
2695
|
}
|
|
2687
2696
|
// Suppress while the user is mid-utterance. Without this, agent text generated
|
|
@@ -4142,6 +4151,17 @@ async function main() {
|
|
|
4142
4151
|
// Close handler with auto-recovery for crashes (both realtime and direct modes)
|
|
4143
4152
|
sess.on('close', async (ev) => {
|
|
4144
4153
|
console.log('πͺ Session closed:', ev.reason);
|
|
4154
|
+
// ORPHAN-BOT GUARD (fix 2026-08-01): an abrupt AgentSession close
|
|
4155
|
+
// (e.g. a driver tab killed β reason 'user_initiated', with NO clean
|
|
4156
|
+
// ParticipantDisconnected) must not leave the Recall bot stuck in-call
|
|
4157
|
+
// draining credits. Arm the meeting leave-grace so endMeeting() fires
|
|
4158
|
+
// (bot leaves + LLM released) once the room is genuinely empty. A rejoin
|
|
4159
|
+
// cancels it; error/disconnected closes still auto-recover below and the
|
|
4160
|
+
// grace is harmless there (recovery + rejoin cancels it).
|
|
4161
|
+
if (activeMeetingBotId) {
|
|
4162
|
+
console.log('π½οΈ Meeting active on session close β arming leave-grace (bot leaves if no user returns)');
|
|
4163
|
+
armMeetingLeaveGrace();
|
|
4164
|
+
}
|
|
4145
4165
|
// TTS abort from user interruption β SDK already killed the session internally,
|
|
4146
4166
|
// so we MUST recover (can't just reset state β STT pipeline is dead).
|
|
4147
4167
|
// Log it distinctly so we know it's an interrupt recovery, not a real crash.
|
|
@@ -4526,24 +4546,34 @@ async function main() {
|
|
|
4526
4546
|
})();
|
|
4527
4547
|
}
|
|
4528
4548
|
currentAgent = null;
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4549
|
+
if (activeMeetingBotId) {
|
|
4550
|
+
// MEETING SURVIVES A VOICE-SESSION DROP (fix 2026-08-01). The bot's brain
|
|
4551
|
+
// is the persistent Claude LLM; killing it on every participant blip left
|
|
4552
|
+
// the Recall bot permanently DEAF mid-call ("Addressed" fired but never
|
|
4553
|
+
// "Flushed" β currentLLM was null; proven via synthetic transcript inject).
|
|
4554
|
+
// Keep currentLLM alive so meeting transcripts still flush + reply
|
|
4555
|
+
// (speakIntoMeeting is Recall-direct, needs no LiveKit session). The
|
|
4556
|
+
// leave-grace below owns teardown: if the user is truly gone at 75s,
|
|
4557
|
+
// endMeeting() releases the LLM (its !userPresent branch). A rejoin cancels
|
|
4558
|
+
// the grace. DO NOT arm the 20s fast-leave here β it would destroy the room
|
|
4559
|
+
// before the meeting grace and orphan the bot; the meeting grace is the
|
|
4560
|
+
// single teardown while a meeting is live.
|
|
4561
|
+
console.log('π½οΈ Meeting active β keeping currentLLM alive across the voice-session drop (leave-grace owns teardown)');
|
|
4562
|
+
armMeetingLeaveGrace();
|
|
4563
|
+
}
|
|
4564
|
+
else {
|
|
4565
|
+
// Kill the Claude SDK subprocess BEFORE dropping the reference, otherwise the
|
|
4566
|
+
// persistent session keeps running tools and pushing TTS into a dead session.
|
|
4567
|
+
killCurrentLLM('participant_disconnected');
|
|
4568
|
+
currentLLM = null;
|
|
4569
|
+
clearFastBrainSession();
|
|
4570
|
+
clearPipelineFastBrainSession();
|
|
4571
|
+
// Auto-leave path for a NON-meeting session. 0.9.83: a real session just
|
|
4572
|
+
// ended β use the FAST leave (~20s), not the 3-min alone grace. Fires even
|
|
4573
|
+
// on an abrupt tab close; cancelled if a user rejoins within the grace.
|
|
4574
|
+
armMeetingLeaveGrace(); // no-op when no meeting, kept for symmetry
|
|
4575
|
+
armFastLeaveTimer();
|
|
4576
|
+
}
|
|
4547
4577
|
console.log('β³ Waiting for new user...\n');
|
|
4548
4578
|
};
|
|
4549
4579
|
const handleDataReceived = async (payload, participant, kind, topic) => {
|
|
@@ -5210,16 +5240,18 @@ async function main() {
|
|
|
5210
5240
|
// the bot casts the meeting canvas as its camera+mic (below), so
|
|
5211
5241
|
// it can show visuals and speak into the meeting on demand.
|
|
5212
5242
|
await sendToFrontend({ type: 'meeting_joining', message: 'Osborn is joining your meeting...' });
|
|
5213
|
-
// AUDIO
|
|
5214
|
-
//
|
|
5215
|
-
//
|
|
5216
|
-
//
|
|
5217
|
-
//
|
|
5218
|
-
//
|
|
5219
|
-
//
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5243
|
+
// CANVAS + AUDIO (2026-08-01, live-verified A/B): the canvas
|
|
5244
|
+
// webpage camera shows VISUALS (captions, screenshots, transcript
|
|
5245
|
+
// animation) AND Recall output_audio delivers the VOICE β the two
|
|
5246
|
+
// coexist (confirmed: output_audio audible while the canvas camera
|
|
5247
|
+
// was showing). speakIntoMeeting routes voiceβoutput_audio +
|
|
5248
|
+
// captionβcanvas (no audio), so no double-audio. The canvas page's
|
|
5249
|
+
// OWN Web Audio is NOT used for voice (it's suspended headless).
|
|
5250
|
+
const frontendUrl = (process.env.OSBORN_FRONTEND_URL || 'https://www.voice-native.com').replace(/\/$/, '');
|
|
5251
|
+
const canvasUrl = /^https:\/\//.test(webhookBase)
|
|
5252
|
+
? `${frontendUrl}/meeting-canvas?agent=${encodeURIComponent(webhookBase)}`
|
|
5253
|
+
: undefined;
|
|
5254
|
+
const castUrl = data.castUrl || process.env.OSBORN_MEETING_CAST_URL || canvasUrl;
|
|
5223
5255
|
const botId = await recallJoin.joinMeeting(meetingUrl, webhookBase, { castUrl });
|
|
5224
5256
|
const sessionId = currentLLM?.sessionId || currentResumeSessionId || 'default';
|
|
5225
5257
|
recallJoin.registerBot(botId, sessionId);
|