@oh-my-pi/pi-utils 17.2.15 → 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,13 @@
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
+
5
12
  ## [17.2.15] - 2026-08-12
6
13
 
7
14
  ### 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.
@@ -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.15",
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.15"
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
 
@@ -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
  /**