@oh-my-pi/pi-utils 17.2.14 → 17.3.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,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.3.0] - 2026-08-13
6
+
7
+ ### Fixed
8
+
9
+ - Optimized performance of partial JSON parsing for long streaming tool-call arguments.
10
+ - Fixed Mermaid ASCII multi-word edge labels where routed lines would show through spaces.
11
+
12
+ ## [17.2.15] - 2026-08-12
13
+
14
+ ### Changed
15
+
16
+ - Extended parsed Server-Sent Events (SSE) to include optional id and retry fields, enabling reconnecting transports to retain stream cursors and respect server-requested retry intervals.
17
+
5
18
  ## [17.2.13] - 2026-08-11
6
19
 
7
20
  ### Changed
@@ -21,8 +21,8 @@ export declare function parseJsonWithRepair<T>(json: string): T;
21
21
  export declare function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T;
22
22
  /**
23
23
  * Default minimum byte growth before `parseStreamingJsonThrottled` will
24
- * re-parse a streaming tool-call argument buffer. Bounds the mid-stream
25
- * partial-parse cost from quadratic to linear in N.
24
+ * re-parse a streaming tool-call argument buffer. Acts as the floor of the
25
+ * geometric gate see {@link parseStreamingJsonThrottled}.
26
26
  */
27
27
  export declare const STREAMING_JSON_PARSE_MIN_GROWTH = 256;
28
28
  /**
@@ -30,14 +30,23 @@ export declare const STREAMING_JSON_PARSE_MIN_GROWTH = 256;
30
30
  *
31
31
  * Tool calls arrive as a long sequence of small deltas — calling
32
32
  * `parseStreamingJson(buffer)` on every delta re-parses the entire buffer
33
- * each time, giving O(N²) work in the total buffer length. Throttling skips
34
- * the re-parse until at least `minGrowthBytes` of new content has arrived
35
- * since the last successful parse, bounding mid-stream cost to O(N).
33
+ * each time, giving O(N²) work in the total buffer length. A fixed re-parse
34
+ * floor alone does NOT fix this: with `minGrowthBytes` constant, a buffer of
35
+ * length N is parsed N/minGrowthBytes times at an average cost of N/2, which
36
+ * is still O(N²) (the constant just shrinks). Long `write` payloads — where
37
+ * the buffer is the whole file — made this the dominant main-thread stall
38
+ * during streaming.
39
+ *
40
+ * Instead the gate scales geometrically: once the buffer is large, a re-parse
41
+ * requires growth proportional to the current length (`len / 32`, floored at
42
+ * `minGrowthBytes`). Parse points then form a geometric progression, so a
43
+ * buffer of length N is parsed O(log N) times for O(N log N) total work,
44
+ * while small buffers keep the snappy fixed-cadence updates.
36
45
  *
37
46
  * Each provider tracks the last parsed length on its tool-call block, so the
38
47
  * final `toolcall_end` parse (which providers already perform unconditionally)
39
48
  * is the authoritative full parse — the throttle only delays mid-stream UI
40
- * updates by at most `minGrowthBytes` of accumulated partial content.
49
+ * updates, by at most ~3% of the accumulated content for large buffers.
41
50
  *
42
51
  * @returns the parsed object plus the new `parsedLen` to persist; or `null`
43
52
  * when the buffer has not grown enough to warrant a re-parse.
@@ -27,11 +27,16 @@ export declare function readSseJson<T>(stream: ReadableStream<Uint8Array>, signa
27
27
  * - `raw` is the list of decoded non-empty lines that made up the event,
28
28
  * preserved for diagnostic context (error reporting, debugging). The
29
29
  * dispatching blank line is not included.
30
+ * - `id` and `retry` are present only when the event carried valid fields with
31
+ * those names. Control-only events are yielded so reconnecting transports can
32
+ * retain the cursor and server-requested retry interval.
30
33
  */
31
34
  export interface ServerSentEvent {
32
35
  event: string | null;
33
36
  data: string;
34
37
  raw: string[];
38
+ id?: string;
39
+ retry?: number;
35
40
  }
36
41
  /**
37
42
  * Stream raw Server-Sent Events from an HTTP response body.
@@ -6,6 +6,8 @@
6
6
  * right of its lead cell; canvas writes keep the pair atomic.
7
7
  */
8
8
  export declare const WIDE_PAD = "\0";
9
+ /** Opaque label-space placeholder that serializes back to a regular space. */
10
+ export declare const LABEL_SPACE = "\u0001";
9
11
  /**
10
12
  * Display width of a string in terminal columns, summed over grapheme
11
13
  * clusters so it always equals `toCells(text).length`. ASCII-only strings
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "17.2.14",
4
+ "version": "17.3.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": "17.2.14"
34
+ "@oh-my-pi/pi-natives": "17.3.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/bun": "^1.3.14"
package/src/json-parse.ts CHANGED
@@ -579,8 +579,8 @@ export function parseStreamingJson<T = Record<string, unknown>>(partialJson: str
579
579
 
580
580
  /**
581
581
  * Default minimum byte growth before `parseStreamingJsonThrottled` will
582
- * re-parse a streaming tool-call argument buffer. Bounds the mid-stream
583
- * partial-parse cost from quadratic to linear in N.
582
+ * re-parse a streaming tool-call argument buffer. Acts as the floor of the
583
+ * geometric gate see {@link parseStreamingJsonThrottled}.
584
584
  */
585
585
  export const STREAMING_JSON_PARSE_MIN_GROWTH = 256;
586
586
 
@@ -589,14 +589,23 @@ export const STREAMING_JSON_PARSE_MIN_GROWTH = 256;
589
589
  *
590
590
  * Tool calls arrive as a long sequence of small deltas — calling
591
591
  * `parseStreamingJson(buffer)` on every delta re-parses the entire buffer
592
- * each time, giving O(N²) work in the total buffer length. Throttling skips
593
- * the re-parse until at least `minGrowthBytes` of new content has arrived
594
- * since the last successful parse, bounding mid-stream cost to O(N).
592
+ * each time, giving O(N²) work in the total buffer length. A fixed re-parse
593
+ * floor alone does NOT fix this: with `minGrowthBytes` constant, a buffer of
594
+ * length N is parsed N/minGrowthBytes times at an average cost of N/2, which
595
+ * is still O(N²) (the constant just shrinks). Long `write` payloads — where
596
+ * the buffer is the whole file — made this the dominant main-thread stall
597
+ * during streaming.
598
+ *
599
+ * Instead the gate scales geometrically: once the buffer is large, a re-parse
600
+ * requires growth proportional to the current length (`len / 32`, floored at
601
+ * `minGrowthBytes`). Parse points then form a geometric progression, so a
602
+ * buffer of length N is parsed O(log N) times for O(N log N) total work,
603
+ * while small buffers keep the snappy fixed-cadence updates.
595
604
  *
596
605
  * Each provider tracks the last parsed length on its tool-call block, so the
597
606
  * final `toolcall_end` parse (which providers already perform unconditionally)
598
607
  * is the authoritative full parse — the throttle only delays mid-stream UI
599
- * updates by at most `minGrowthBytes` of accumulated partial content.
608
+ * updates, by at most ~3% of the accumulated content for large buffers.
600
609
  *
601
610
  * @returns the parsed object plus the new `parsedLen` to persist; or `null`
602
611
  * when the buffer has not grown enough to warrant a re-parse.
@@ -607,7 +616,9 @@ export function parseStreamingJsonThrottled<T = Record<string, unknown>>(
607
616
  minGrowthBytes: number = STREAMING_JSON_PARSE_MIN_GROWTH,
608
617
  ): { value: T; parsedLen: number } | null {
609
618
  const len = partialJson?.length ?? 0;
610
- if (len === 0 || (lastParsedLen > 0 && len - lastParsedLen < minGrowthBytes)) return null;
619
+ if (len === 0) return null;
620
+ const growth = Math.max(minGrowthBytes, len >> 5);
621
+ if (lastParsedLen > 0 && len - lastParsedLen < growth) return null;
611
622
  return { value: parseStreamingJson<T>(partialJson), parsedLen: len };
612
623
  }
613
624
 
package/src/stream.ts CHANGED
@@ -281,11 +281,16 @@ export async function* readSseJson<T>(
281
281
  * - `raw` is the list of decoded non-empty lines that made up the event,
282
282
  * preserved for diagnostic context (error reporting, debugging). The
283
283
  * dispatching blank line is not included.
284
+ * - `id` and `retry` are present only when the event carried valid fields with
285
+ * those names. Control-only events are yielded so reconnecting transports can
286
+ * retain the cursor and server-requested retry interval.
284
287
  */
285
288
  export interface ServerSentEvent {
286
289
  event: string | null;
287
290
  data: string;
288
291
  raw: string[];
292
+ id?: string;
293
+ retry?: number;
289
294
  }
290
295
 
291
296
  interface SseEventState {
@@ -296,6 +301,8 @@ interface SseEventState {
296
301
  // seen yet" (distinct from a `data:` field with an empty value).
297
302
  data: string | null;
298
303
  raw: string[];
304
+ id?: string;
305
+ retry?: number;
299
306
  }
300
307
 
301
308
  // Complete lines are decoded in one batch per source chunk. Each batch ends on
@@ -303,7 +310,7 @@ interface SseEventState {
303
310
  const SSE_DECODER = new TextDecoder("utf-8");
304
311
 
305
312
  function flushSseEvent(state: SseEventState): ServerSentEvent | null {
306
- if (state.event === null && state.data === null) {
313
+ if (state.event === null && state.data === null && state.id === undefined && state.retry === undefined) {
307
314
  state.raw = [];
308
315
  return null;
309
316
  }
@@ -312,9 +319,13 @@ function flushSseEvent(state: SseEventState): ServerSentEvent | null {
312
319
  data: state.data ?? "",
313
320
  raw: state.raw,
314
321
  };
322
+ if (state.id !== undefined) event.id = state.id;
323
+ if (state.retry !== undefined) event.retry = state.retry;
315
324
  state.event = null;
316
325
  state.data = null;
317
326
  state.raw = [];
327
+ state.id = undefined;
328
+ state.retry = undefined;
318
329
  return event;
319
330
  }
320
331
 
@@ -348,9 +359,22 @@ function pushSseLine(line: string, state: SseEventState): ServerSentEvent | null
348
359
  state.data += "\n";
349
360
  state.data += value;
350
361
  }
362
+ } else if (fieldName === "id") {
363
+ if (!value.includes("\0")) state.id = value;
364
+ } else if (fieldName === "retry" && value.length > 0) {
365
+ let valid = true;
366
+ for (let index = 0; index < value.length; index++) {
367
+ const code = value.charCodeAt(index);
368
+ if (code < 0x30 || code > 0x39) {
369
+ valid = false;
370
+ break;
371
+ }
372
+ }
373
+ if (valid) {
374
+ const retry = Number(value);
375
+ if (Number.isSafeInteger(retry)) state.retry = retry;
376
+ }
351
377
  }
352
- // `id` and `retry` are intentionally ignored — the providers we consume
353
- // don't use them, and the underlying transport handles reconnects itself.
354
378
  return null;
355
379
  }
356
380
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  import type { Canvas, DrawingCoord, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types'
10
10
  import { colorizeLine, DEFAULT_ASCII_THEME } from './ansi'
11
- import { displayWidth, toCells, WIDE_PAD } from '../text-metrics'
11
+ import { displayWidth, LABEL_SPACE, toCells, WIDE_PAD } from '../text-metrics'
12
12
 
13
13
  /**
14
14
  * Create a blank canvas filled with spaces.
@@ -189,7 +189,7 @@ export function isJunctionChar(c: string): boolean {
189
189
  * letter/digit test misses.
190
190
  */
191
191
  function isLabelChar(c: string): boolean {
192
- return c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c)
192
+ return c === LABEL_SPACE || c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c)
193
193
  }
194
194
 
195
195
  /**
@@ -268,7 +268,7 @@ export function mergeCanvases(
268
268
  for (let x = 0; x < overlay.length; x++) {
269
269
  for (let y = 0; y < overlay[0]!.length; y++) {
270
270
  const c = overlay[x]![y]!
271
- // WIDE_PAD cells are written atomically with their lead below
271
+ // Spaces are transparent; WIDE_PAD cells are written atomically with their lead below
272
272
  if (c === ' ' || c === WIDE_PAD) continue
273
273
  const mx = x + offset.x
274
274
  const my = y + offset.y
@@ -327,8 +327,8 @@ export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions):
327
327
  let line = ''
328
328
  for (let x = 0; x <= maxX; x++) {
329
329
  const c = canvas[x]![y]!
330
- // Skip wide-glyph continuation cells: the glyph itself spans 2 columns
331
- if (c !== WIDE_PAD) line += c
330
+ // Skip wide-glyph continuation cells and restore opaque label spaces.
331
+ if (c !== WIDE_PAD) line += c === LABEL_SPACE ? ' ' : c
332
332
  }
333
333
  lines.push(line)
334
334
  } else {
@@ -338,7 +338,7 @@ export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions):
338
338
  for (let x = 0; x <= maxX; x++) {
339
339
  const c = canvas[x]![y]!
340
340
  if (c === WIDE_PAD) continue
341
- chars.push(c)
341
+ chars.push(c === LABEL_SPACE ? ' ' : c)
342
342
  roles.push(roleCanvas[x]?.[y] ?? null)
343
343
  }
344
344
  lines.push(colorizeLine(chars, roles, theme, colorMode))
@@ -21,7 +21,7 @@ import { gridToDrawingCoord, lineToDrawing } from './grid'
21
21
  import { splitLines } from './multiline-utils'
22
22
  import { getCorners } from './shapes/corners'
23
23
  import { getShapeAttachmentPoint } from './shapes/index'
24
- import { displayWidth, toCells, WIDE_PAD } from '../text-metrics'
24
+ import { displayWidth, LABEL_SPACE, toCells, WIDE_PAD } from '../text-metrics'
25
25
 
26
26
  // ============================================================================
27
27
  // Node drawing — renders a node using shape-aware rendering
@@ -679,7 +679,7 @@ function drawTextOnLine(canvas: Canvas, line: DrawingCoord[], label: string, isU
679
679
  for (let i = 0; i < lines.length; i++) {
680
680
  const lineText = lines[i]!
681
681
  const startX = middleX - Math.floor(displayWidth(lineText) / 2)
682
- drawText(canvas, { x: startX, y: startY + i }, lineText)
682
+ drawText(canvas, { x: startX, y: startY + i }, lineText.replaceAll(' ', LABEL_SPACE))
683
683
  }
684
684
  }
685
685
 
@@ -29,6 +29,9 @@
29
29
  */
30
30
  export const WIDE_PAD = '\u0000'
31
31
 
32
+ /** Opaque label-space placeholder that serializes back to a regular space. */
33
+ export const LABEL_SPACE = '\u0001'
34
+
32
35
  const graphemeSegmenter = new Intl.Segmenter()
33
36
 
34
37
  /**