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.
Files changed (2) hide show
  1. package/dist/index.js +90 -58
  2. 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
- // Suppress browser TTS for meeting-chunk responses β€” the agent speaks INTO
2657
- // the meeting via /canvas, not the laptop speakers (which the Meet mic
2658
- // re-captures in the same room β†’ feedback). Set by PipelineDirectLLM.chat()
2659
- // when the turn is a [MEETING β€”] chunk. Normal user turns are unaffected.
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
- // MEETING MODE CONDITION (user directive 2026-08-01): says route to
2662
- // the NATIVE Recall sink by default β€” no room/Chrome relay (that path
2663
- // was moderately-to-extremely choppy). The LiveKit parity relay stays
2664
- // available behind OSBORN_MEETING_AUDIO=parity for future testing.
2665
- if (process.env.OSBORN_MEETING_AUDIO === 'parity' && meetingCanvasInRoom && activeMeetingBotId && Date.now() < meetingAddressedUntil) {
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
- // no return β€” normal say proceeds below
2669
- }
2670
- else
2671
- // ADDRESSED turn β†’ REDIRECT the normal streaming reply into the meeting
2672
- // (reuse of the regular speak path; no Bash roundtrip). Silent-observer
2673
- // turns stay suppressed as before.
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
- console.log(`πŸ”‡ tts_say suppressed (meeting turn β€” response goes to /canvas, not browser): "${data.text.slice(0, 60)}"`);
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
- // Kill the Claude SDK subprocess BEFORE dropping the reference, otherwise the
4530
- // persistent session keeps running tools and pushing TTS into a dead session.
4531
- killCurrentLLM('participant_disconnected');
4532
- currentLLM = null;
4533
- clearFastBrainSession();
4534
- clearPipelineFastBrainSession();
4535
- // Auto-leave any active meeting bot when user disconnects from the room.
4536
- // DELIBERATE COUPLING: the bot's lifecycle follows the voice session β€” a
4537
- // decoupled always-on meeting bot means untracked background agents (no
4538
- // status surface for them yet). Revisit only WITH a tracking UI.
4539
- // endMeeting() centralizes the teardown (flush, poller, max-timer, Recall
4540
- // leave, frontend notify).
4541
- armMeetingLeaveGrace();
4542
- // 0.9.83: a real session just ended β†’ use the FAST leave (~20s), not the
4543
- // 3-min alone grace. Runs on the agent, so it fires even on an abrupt tab
4544
- // close. Cancelled if a user rejoins within the grace (handled where the
4545
- // participant-join cancels timers).
4546
- armFastLeaveTimer();
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-FIRST DEFAULT (2026-08-01, proven live): the webpage-camera
5214
- // output_media routes the bot's AUDIO through the canvas page's
5215
- // Web Audio β€” which sits SUSPENDED in Recall's headless Chrome (no
5216
- // user gesture) β†’ the bot was inaudible all night. Default now = NO
5217
- // webpage camera, so the bot's voice is the direct Recall
5218
- // output_audio track (speakIntoMeeting β†’ recall.outputAudio, ~1.8s
5219
- // synth, verified audible + clear). The browser CAST is opt-in via
5220
- // explicit data.castUrl / OSBORN_MEETING_CAST_URL until we split
5221
- // video(webpage) from audio(output_audio) to reclaim it cleanly.
5222
- const castUrl = data.castUrl || process.env.OSBORN_MEETING_CAST_URL || undefined;
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.116",
3
+ "version": "0.9.118",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {