osborn 0.9.147 → 0.9.148

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.
@@ -552,6 +552,10 @@ export class ClaudeLLM extends llm.LLM {
552
552
  // No JSONL replay after the first cold start.
553
553
  #persistentQuery = null;
554
554
  #messageChannel = null;
555
+ // Read-along tracking — per-turn message ID and chunk counter for TTS highlighting
556
+ #currentTurnMessageId = null;
557
+ #currentTurnChunkIndex = 0;
558
+ #currentTurnChunks = [];
555
559
  #backgroundConsumerRunning = false;
556
560
  // Active queries — multiple can be running (SDK queues them internally).
557
561
  // We keep ALL references so interrupt() can stop whatever is currently executing.
@@ -1106,22 +1110,41 @@ export class ClaudeLLM extends llm.LLM {
1106
1110
  }
1107
1111
  // Stream assistant text → tts_say events
1108
1112
  if (msg.type === 'assistant' && msg.message?.content) {
1113
+ // Assign a stable messageId for this turn (first block sets it, rest reuse)
1114
+ if (!this.#currentTurnMessageId) {
1115
+ this.#currentTurnMessageId = crypto.randomUUID();
1116
+ this.#currentTurnChunkIndex = 0;
1117
+ this.#currentTurnChunks = [];
1118
+ }
1119
+ const turnMessageId = this.#currentTurnMessageId;
1109
1120
  for (const block of msg.message.content) {
1110
1121
  if (block.type === 'text' && block.text) {
1111
- callbacks.eventEmitter.emit('assistant_text', { text: block.text });
1122
+ const chunkIndex = this.#currentTurnChunkIndex;
1123
+ callbacks.eventEmitter.emit('assistant_text', { text: block.text, messageId: turnMessageId, chunkIndex });
1112
1124
  const ttsChunk = stripMarkdownForTTS(block.text);
1113
1125
  if (ttsChunk.trim()) {
1126
+ this.#currentTurnChunks.push(ttsChunk);
1127
+ this.#currentTurnChunkIndex++;
1114
1128
  console.log(`🔊 TTS say (${ttsChunk.length} chars): "${ttsChunk}"`);
1115
- callbacks.eventEmitter.emit('tts_say', { text: ttsChunk });
1129
+ callbacks.eventEmitter.emit('tts_say', { text: ttsChunk, messageId: turnMessageId, chunkIndex });
1116
1130
  }
1117
1131
  }
1118
1132
  }
1119
1133
  }
1120
1134
  // Result — marks end of a turn (but we keep consuming for next turn)
1121
1135
  if (msg.type === 'result') {
1136
+ const turnMessageId = this.#currentTurnMessageId;
1137
+ const turnChunks = [...this.#currentTurnChunks];
1122
1138
  if (msg.result) {
1123
- callbacks.eventEmitter.emit('assistant_result', { text: msg.result });
1139
+ callbacks.eventEmitter.emit('assistant_result', { text: msg.result, messageId: turnMessageId });
1140
+ }
1141
+ if (turnMessageId && turnChunks.length > 0) {
1142
+ callbacks.eventEmitter.emit('tts_chunks', { messageId: turnMessageId, chunks: turnChunks });
1124
1143
  }
1144
+ // Reset per-turn state for next turn
1145
+ this.#currentTurnMessageId = null;
1146
+ this.#currentTurnChunkIndex = 0;
1147
+ this.#currentTurnChunks = [];
1125
1148
  console.log('✅ Claude turn complete (persistent session stays alive)');
1126
1149
  }
1127
1150
  }
@@ -1854,6 +1877,10 @@ class ClaudeLLMStream extends llm.LLMStream {
1854
1877
  // Run Claude Agent SDK query() and stream results
1855
1878
  let hasOutput = false;
1856
1879
  let fullResponse = ''; // Collect full response for frontend
1880
+ // Per-turn read-along tracking (non-skipTTSQueue path)
1881
+ let streamTurnMessageId = null;
1882
+ let streamTurnChunkIndex = 0;
1883
+ let streamTurnChunks = [];
1857
1884
  // DIRECT MODE OPTIMIZATION: When skipTTSQueue is true, we run the Claude query
1858
1885
  // in the background and return from run() immediately. This is critical because:
1859
1886
  //
@@ -1940,20 +1967,29 @@ class ClaudeLLMStream extends llm.LLMStream {
1940
1967
  if (sdkRequestId) {
1941
1968
  this.#eventEmitter.emit('query_request_id', { requestId: sdkRequestId });
1942
1969
  }
1970
+ // Assign a stable messageId for this turn (first block sets it, rest reuse)
1971
+ if (!streamTurnMessageId) {
1972
+ streamTurnMessageId = crypto.randomUUID();
1973
+ streamTurnChunkIndex = 0;
1974
+ streamTurnChunks = [];
1975
+ }
1943
1976
  for (const block of message.message.content) {
1944
1977
  if (block.type === 'text' && block.text) {
1945
1978
  hasOutput = true;
1946
1979
  const rawText = block.text;
1980
+ const chunkIndex = streamTurnChunkIndex;
1947
1981
  // Emit RAW text to frontend (for chat bubbles with full formatting)
1948
- this.#eventEmitter.emit('assistant_text', { text: rawText });
1982
+ this.#eventEmitter.emit('assistant_text', { text: rawText, messageId: streamTurnMessageId, chunkIndex });
1949
1983
  // Strip markdown for clean speech
1950
1984
  const ttsChunk = stripMarkdownForTTS(rawText);
1951
1985
  if (ttsChunk.trim()) {
1986
+ streamTurnChunks.push(ttsChunk);
1987
+ streamTurnChunkIndex++;
1952
1988
  if (this.#opts.skipTTSQueue) {
1953
1989
  // Direct mode: emit event for session.say() — bypasses LiveKit's
1954
1990
  // BufferedTokenStream which causes stuck/delayed/out-of-order audio
1955
1991
  console.log(`🔊 TTS say (${ttsChunk.length} chars): "${ttsChunk}"`);
1956
- this.#eventEmitter.emit('tts_say', { text: ttsChunk });
1992
+ this.#eventEmitter.emit('tts_say', { text: ttsChunk, messageId: streamTurnMessageId, chunkIndex });
1957
1993
  }
1958
1994
  else {
1959
1995
  // Realtime mode: use LLM stream queue (framework handles TTS)
@@ -1971,7 +2007,15 @@ class ClaudeLLMStream extends llm.LLMStream {
1971
2007
  if (message.type === 'result' && message.result) {
1972
2008
  const rawResult = message.result;
1973
2009
  // Emit RAW result to frontend
1974
- this.#eventEmitter.emit('assistant_result', { text: rawResult });
2010
+ this.#eventEmitter.emit('assistant_result', { text: rawResult, messageId: streamTurnMessageId });
2011
+ // Emit ordered chunk list for frontend read-along
2012
+ if (streamTurnMessageId && streamTurnChunks.length > 0) {
2013
+ this.#eventEmitter.emit('tts_chunks', { messageId: streamTurnMessageId, chunks: streamTurnChunks });
2014
+ }
2015
+ // Reset per-turn state
2016
+ streamTurnMessageId = null;
2017
+ streamTurnChunkIndex = 0;
2018
+ streamTurnChunks = [];
1975
2019
  if (!hasOutput) {
1976
2020
  hasOutput = true;
1977
2021
  const ttsText = stripMarkdownForTTS(rawResult);
package/dist/index.js CHANGED
@@ -2814,6 +2814,8 @@ async function main() {
2814
2814
  text: data.text,
2815
2815
  isStreaming: true,
2816
2816
  agentRole: 'direct',
2817
+ messageId: data.messageId,
2818
+ chunkIndex: data.chunkIndex,
2817
2819
  });
2818
2820
  });
2819
2821
  // Wire up Claude final result - RAW result goes to frontend
@@ -2825,6 +2827,15 @@ async function main() {
2825
2827
  isStreaming: false,
2826
2828
  isFinal: true,
2827
2829
  agentRole: 'direct',
2830
+ messageId: data.messageId,
2831
+ });
2832
+ });
2833
+ // Wire up ordered TTS chunk list — emitted once per turn for read-along
2834
+ directLLM.events.on('tts_chunks', (data) => {
2835
+ sendToFrontend({
2836
+ type: 'tts_chunks',
2837
+ messageId: data.messageId,
2838
+ chunks: data.chunks,
2828
2839
  });
2829
2840
  });
2830
2841
  // Wire up permission requests - sends to frontend for user approval
@@ -3001,6 +3012,15 @@ async function main() {
3001
3012
  playbackStartedAt = Date.now();
3002
3013
  console.log(`🔊 [${sayId}] audio first frame out (playbackStarted)`);
3003
3014
  audioOutputRef.off('playbackStarted', onPlaybackStarted);
3015
+ // Notify frontend that this TTS chunk is now audibly playing
3016
+ if (data.messageId != null && data.chunkIndex != null) {
3017
+ sendToFrontend({
3018
+ type: 'tts_chunk_playing',
3019
+ messageId: data.messageId,
3020
+ chunkIndex: data.chunkIndex,
3021
+ text: data.text,
3022
+ });
3023
+ }
3004
3024
  };
3005
3025
  audioOutputRef.on('playbackStarted', onPlaybackStarted);
3006
3026
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.147",
3
+ "version": "0.9.148",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {