bloby-bot 0.47.4 → 0.47.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.47.4",
3
+ "version": "0.47.5",
4
4
  "releaseNotes": [
5
5
  "1. # voice note (PTT bubble)",
6
6
  "2. # audio file + caption",
@@ -18,35 +18,70 @@ import type {
18
18
  } from './types.js';
19
19
 
20
20
  /** Walk an SSE byte stream and yield each parsed JSON event. */
21
- async function* parseSse(res: Response): AsyncIterable<any> {
21
+ async function* parseSse(res: Response, dbg: { firstBytes: string }): AsyncIterable<any> {
22
22
  if (!res.body) return;
23
23
  const reader = res.body.getReader();
24
24
  const decoder = new TextDecoder();
25
25
  let buffer = '';
26
+ let totalBytes = 0;
26
27
  try {
27
28
  while (true) {
28
29
  const { value, done } = await reader.read();
29
30
  if (done) break;
31
+ if (value) totalBytes += value.byteLength;
30
32
  buffer += decoder.decode(value, { stream: true });
31
- // SSE event boundary is a blank line. Process every complete event in buffer.
33
+ if (!dbg.firstBytes && buffer.length > 0) {
34
+ dbg.firstBytes = buffer.slice(0, 800);
35
+ }
36
+ // SSE event boundary is a blank line. Accept both LF and CRLF separators.
32
37
  let idx;
33
- while ((idx = buffer.indexOf('\n\n')) !== -1) {
38
+ while (
39
+ (idx = (() => {
40
+ const a = buffer.indexOf('\n\n');
41
+ const b = buffer.indexOf('\r\n\r\n');
42
+ if (a < 0) return b;
43
+ if (b < 0) return a;
44
+ return Math.min(a, b);
45
+ })()) !== -1
46
+ ) {
47
+ const isCrlf = buffer.slice(idx, idx + 4) === '\r\n\r\n';
34
48
  const raw = buffer.slice(0, idx);
35
- buffer = buffer.slice(idx + 2);
36
- const dataLines = raw.split('\n').filter((l) => l.startsWith('data:'));
37
- if (!dataLines.length) continue;
38
- const data = dataLines.map((l) => l.slice(5).trimStart()).join('\n');
39
- if (!data || data === '[DONE]') continue;
40
- try {
41
- yield JSON.parse(data);
42
- } catch {
43
- // Skip malformed chunks rather than killing the whole turn.
44
- }
49
+ buffer = buffer.slice(idx + (isCrlf ? 4 : 2));
50
+ const parsed = parseSseEvent(raw);
51
+ if (parsed !== undefined) yield parsed;
45
52
  }
46
53
  }
54
+ // Flush whatever remains — Gemini's final event may not have a trailing blank line.
55
+ buffer += decoder.decode();
56
+ if (buffer.trim()) {
57
+ const parsed = parseSseEvent(buffer);
58
+ if (parsed !== undefined) yield parsed;
59
+ }
47
60
  } finally {
48
61
  try { reader.releaseLock(); } catch {}
62
+ dbg.firstBytes = dbg.firstBytes || `(zero bytes — total=${totalBytes})`;
63
+ }
64
+ }
65
+
66
+ function parseSseEvent(raw: string): any | undefined {
67
+ // Standard SSE: one or more `data:` lines per event. Concatenate their payloads.
68
+ const lines = raw.split(/\r?\n/);
69
+ const dataLines = lines
70
+ .filter((l) => l.startsWith('data:'))
71
+ .map((l) => l.slice(5).trimStart());
72
+ if (!dataLines.length) {
73
+ // Fallback: some servers omit the `data:` prefix and send pure JSON per event.
74
+ const trimmed = raw.trim();
75
+ if (!trimmed || trimmed === '[DONE]') return undefined;
76
+ // Strip a leading JSON-array delimiter if Gemini is returning array-stream
77
+ // instead of SSE (alt=sse not honored).
78
+ const candidate = trimmed.replace(/^[\[,]/, '').replace(/[\],]$/, '').trim();
79
+ if (!candidate) return undefined;
80
+ try { return JSON.parse(candidate); } catch { return undefined; }
49
81
  }
82
+ const data = dataLines.join('\n');
83
+ if (!data || data === '[DONE]') return undefined;
84
+ try { return JSON.parse(data); } catch { return undefined; }
50
85
  }
51
86
 
52
87
  function toGeminiRole(role: PiMessage['role']): 'user' | 'model' {
@@ -155,9 +190,10 @@ export async function* streamGoogle(req: PiStreamRequest): AsyncIterable<PiStrea
155
190
  let thoughtPartCount = 0;
156
191
  let emptyTextPartCount = 0;
157
192
  let firstChunkSummary = '';
193
+ const dbg = { firstBytes: '' };
158
194
 
159
195
  try {
160
- for await (const chunk of parseSse(res)) {
196
+ for await (const chunk of parseSse(res, dbg)) {
161
197
  chunkCount++;
162
198
  if (chunkCount === 1) {
163
199
  try { firstChunkSummary = JSON.stringify(chunk).slice(0, 600); } catch {}
@@ -206,7 +242,8 @@ export async function* streamGoogle(req: PiStreamRequest): AsyncIterable<PiStrea
206
242
  if (chunkCount > 0 && !accumulated) {
207
243
  log.info(`[pi/google] first chunk (truncated): ${firstChunkSummary}`);
208
244
  } else if (chunkCount === 0) {
209
- log.warn(`[pi/google] SSE stream parsed zero chunks — check response shape (status=${res.status} content-type=${res.headers.get('content-type') || ''})`);
245
+ log.warn(`[pi/google] SSE stream parsed zero chunks — content-type=${res.headers.get('content-type') || '?'}`);
246
+ log.warn(`[pi/google] first raw bytes: ${JSON.stringify(dbg.firstBytes)}`);
210
247
  }
211
248
 
212
249
  // Prompt-level block: nothing came back at all.