@oh-my-pi/pi-utils 16.5.2 → 17.0.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.0] - 2026-07-15
6
+
7
+ ### Fixed
8
+
9
+ - Improved SSE streaming performance by batching complete lines into a single UTF-8 decode per chunk, reducing decoder overhead.
10
+ - Fixed an issue in `parseFrontmatter` where a single malformed YAML line would corrupt sibling values by parsing each line independently.
11
+
5
12
  ## [16.5.2] - 2026-07-14
6
13
 
7
14
  ### Fixed
@@ -41,9 +41,8 @@ export interface ServerSentEvent {
41
41
  * Use `readSseJson` instead when every event is a single `data:` JSON object
42
42
  * and you don't need access to the `event:` field.
43
43
  *
44
- * Internally backed by a Buffer-based line reader (`ConcatSink`) so chunk
45
- * concatenation is O(n) and never triggers per-line string slicing of the
46
- * accumulated buffer.
44
+ * Internally backed by a Buffer-based reader (`ConcatSink`) that batches all
45
+ * complete lines in each source chunk into one UTF-8 decode.
47
46
  *
48
47
  * @example
49
48
  * ```ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "16.5.2",
4
+ "version": "17.0.0",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "16.5.2",
34
+ "@oh-my-pi/pi-natives": "17.0.0",
35
35
  "handlebars": "^4.7.9",
36
36
  "winston": "^3.19.0",
37
37
  "winston-daily-rotate-file": "^5.0.0"
@@ -149,12 +149,25 @@ export function parseFrontmatter(
149
149
  throw err;
150
150
  }
151
151
 
152
- // Simple YAML parsing - just key: value pairs
152
+ // Simple key: value fallback. Reparse each value on its own so one
153
+ // malformed line (e.g. `scope: "text","thinking"`) can't leave sibling
154
+ // values wrapped in literal quotes; values that don't parse as YAML fall
155
+ // back to the raw trimmed string (issue #4796).
153
156
  for (const line of metadata.split("\n")) {
154
157
  const match = line.match(/^([\w-]+):\s*(.*)$/);
155
- if (match) {
156
- frontmatter[match[1]] = match[2].trim();
158
+ if (!match) continue;
159
+ const raw = match[2].trim();
160
+ let value: unknown = raw;
161
+ if (raw.length > 0) {
162
+ try {
163
+ const parsed = YAML.parse(raw);
164
+ if (parsed !== null && typeof parsed !== "object") value = parsed;
165
+ else if (Array.isArray(parsed)) value = parsed;
166
+ } catch {
167
+ // keep the raw string
168
+ }
157
169
  }
170
+ frontmatter[match[1]] = value;
158
171
  }
159
172
 
160
173
  return { frontmatter: normalizeKeys(frontmatter) as Record<string, unknown>, body };
package/src/stream.ts CHANGED
@@ -150,6 +150,29 @@ class ConcatSink {
150
150
  }
151
151
  }
152
152
  }
153
+
154
+ appendAndFlushText(chunk: Uint8Array, decoder: TextDecoder): string | undefined {
155
+ const lastNewline = chunk.lastIndexOf(LF);
156
+ if (lastNewline === -1) {
157
+ this.append(chunk);
158
+ return undefined;
159
+ }
160
+
161
+ const completeEnd = lastNewline + 1;
162
+ let text: string;
163
+ if (this.isEmpty) {
164
+ const complete = completeEnd === chunk.length ? chunk : chunk.subarray(0, completeEnd);
165
+ text = decoder.decode(complete);
166
+ } else {
167
+ this.append(completeEnd === chunk.length ? chunk : chunk.subarray(0, completeEnd));
168
+ text = decoder.decode(this.flush());
169
+ this.clear();
170
+ }
171
+ if (completeEnd < chunk.length) {
172
+ this.append(chunk.subarray(completeEnd));
173
+ }
174
+ return text;
175
+ }
153
176
  *pullJSONL<T>(chunk: Uint8Array, beg: number, end: number) {
154
177
  if (this.isEmpty) {
155
178
  const { values, error, read, done } = parseJsonlChunkCompat(chunk, beg, end);
@@ -275,14 +298,9 @@ interface SseEventState {
275
298
  raw: string[];
276
299
  }
277
300
 
278
- // Single decoder reused for all line decodes. Safe because lines are split on
279
- // LF (0x0a) which is always a single-byte ASCII char in UTF-8 and never appears
280
- // inside a multi-byte sequence — so each line is itself a complete UTF-8 run.
281
- const SSE_LINE_DECODER = new TextDecoder("utf-8");
282
-
283
- function decodeSseLineBytes(line: Uint8Array, end: number): string {
284
- return end === line.length ? SSE_LINE_DECODER.decode(line) : SSE_LINE_DECODER.decode(line.subarray(0, end));
285
- }
301
+ // Complete lines are decoded in one batch per source chunk. Each batch ends on
302
+ // LF, which cannot split a multi-byte UTF-8 sequence.
303
+ const SSE_DECODER = new TextDecoder("utf-8");
286
304
 
287
305
  function flushSseEvent(state: SseEventState): ServerSentEvent | null {
288
306
  if (state.event === null && state.data === null) {
@@ -300,25 +318,25 @@ function flushSseEvent(state: SseEventState): ServerSentEvent | null {
300
318
  return event;
301
319
  }
302
320
 
303
- function pushSseLine(line: Uint8Array, state: SseEventState): ServerSentEvent | null {
304
- // `appendAndFlushLines` splits on LF only; strip a trailing CR so CRLF sources
321
+ function pushSseLine(line: string, state: SseEventState): ServerSentEvent | null {
322
+ // Complete-line batches split on LF only; strip a trailing CR so CRLF sources
305
323
  // don't leak `\r` into field values.
306
- let end = line.length;
307
- if (end > 0 && line[end - 1] === 0x0d /* '\r' */) end--;
308
- if (end === 0) return flushSseEvent(state);
324
+ if (line.charCodeAt(line.length - 1) === 0x0d /* '\r' */) {
325
+ line = line.slice(0, -1);
326
+ }
327
+ if (line.length === 0) return flushSseEvent(state);
309
328
 
310
329
  // Comment line: keep in `raw` for diagnostic context, skip parsing.
311
- if (line[0] === 0x3a /* ':' */) {
312
- state.raw.push(decodeSseLineBytes(line, end));
330
+ if (line.charCodeAt(0) === 0x3a /* ':' */) {
331
+ state.raw.push(line);
313
332
  return null;
314
333
  }
315
334
 
316
- const text = decodeSseLineBytes(line, end);
317
- state.raw.push(text);
335
+ state.raw.push(line);
318
336
 
319
- const colon = text.indexOf(":");
320
- const fieldName = colon === -1 ? text : text.slice(0, colon);
321
- let value = colon === -1 ? "" : text.slice(colon + 1);
337
+ const colon = line.indexOf(":");
338
+ const fieldName = colon === -1 ? line : line.slice(0, colon);
339
+ let value = colon === -1 ? "" : line.slice(colon + 1);
322
340
  if (value.charCodeAt(0) === 0x20 /* ' ' */) value = value.slice(1);
323
341
 
324
342
  if (fieldName === "event") {
@@ -344,9 +362,8 @@ function pushSseLine(line: Uint8Array, state: SseEventState): ServerSentEvent |
344
362
  * Use `readSseJson` instead when every event is a single `data:` JSON object
345
363
  * and you don't need access to the `event:` field.
346
364
  *
347
- * Internally backed by a Buffer-based line reader (`ConcatSink`) so chunk
348
- * concatenation is O(n) and never triggers per-line string slicing of the
349
- * accumulated buffer.
365
+ * Internally backed by a Buffer-based reader (`ConcatSink`) that batches all
366
+ * complete lines in each source chunk into one UTF-8 decode.
350
367
  *
351
368
  * @example
352
369
  * ```ts
@@ -365,9 +382,14 @@ export async function* readSseEvents(
365
382
  const source = abortableSource(stream, signal);
366
383
  try {
367
384
  for await (const chunk of source) {
368
- for (const line of lineBuffer.appendAndFlushLines(chunk)) {
369
- const event = pushSseLine(line, state);
385
+ const text = lineBuffer.appendAndFlushText(chunk, SSE_DECODER);
386
+ if (text === undefined) continue;
387
+ let start = 0;
388
+ while (start < text.length) {
389
+ const newline = text.indexOf("\n", start);
390
+ const event = pushSseLine(text.slice(start, newline), state);
370
391
  if (event) yield event;
392
+ start = newline + 1;
371
393
  }
372
394
  }
373
395
  // Treat any trailing partial line (no terminating LF) as a complete line.
@@ -375,7 +397,7 @@ export async function* readSseEvents(
375
397
  const tail = lineBuffer.flush();
376
398
  if (tail) {
377
399
  lineBuffer.clear();
378
- const event = pushSseLine(tail, state);
400
+ const event = pushSseLine(SSE_DECODER.decode(tail), state);
379
401
  if (event) {
380
402
  trailingEvents.add(event);
381
403
  yield event;