@oh-my-pi/pi-utils 18.8.8 → 18.9.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,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.9.0] - 2026-10-11
6
+
7
+ ### Breaking Changes
8
+
9
+ - Renamed `takeRecentLoopPhase()` to `takeLoopPhaseAttribution()`. The new API returns a `LoopPhaseAttribution` object (`{ label, ms }`) and reports a label only when it accounts for more time than unlabeled work within an armed measurement window.
10
+
11
+ ### Added
12
+
13
+ - Added `withLoopPhase(label, fn)` for attributing synchronous work to a named loop phase, with automatic cleanup even when the callback throws.
14
+
15
+ ### Changed
16
+
17
+ - Improved streaming JSON parsing performance for long tool-argument payloads.
18
+
19
+ ### Fixed
20
+
21
+ - Fixed archive extraction on Windows systems without symlink privileges by allowing symlink entries to be extracted using supported fallbacks, while skipping unresolvable dangling links instead of failing the entire archive.
22
+
23
+ ## [18.8.9] - 2026-10-10
24
+
25
+ ### Added
26
+
27
+ - Added `hueDistance`, the angular distance between two OKLCH hues
28
+
5
29
  ## [18.8.8] - 2026-10-10
6
30
 
7
31
  ### Fixed
@@ -111,6 +111,8 @@ export declare function oklchCusp(h: number): {
111
111
  l: number;
112
112
  c: number;
113
113
  };
114
+ /** Angular distance between two hues in degrees (0–180), wrapping at 360. */
115
+ export declare function hueDistance(a: number, b: number): number;
114
116
  /**
115
117
  * Convert OKLCH to a CSS hex string, gamut-mapping by chroma reduction.
116
118
  *
@@ -1,10 +1,15 @@
1
+ /** A label and its exclusive in-window time, as named by {@link takeLoopPhaseAttribution}. */
2
+ export interface LoopPhaseAttribution {
3
+ readonly label: string;
4
+ /** Total time this label was the innermost active phase inside the late window [start, end]. */
5
+ readonly ms: number;
6
+ }
1
7
  export declare function pushLoopPhase(label: string): void;
2
8
  export declare function popLoopPhase(): void;
3
9
  export declare function currentLoopPhase(): string | undefined;
4
- /**
5
- * Phase to blame for a just-detected loop block: the live top phase if one is
6
- * still held, else the most recent phase pushed since the last call. Clears the
7
- * recent slot so a block in a later, phase-less interval is not misattributed
8
- * to a phase that already finished.
9
- */
10
- export declare function takeRecentLoopPhase(): string | undefined;
10
+ /** Run `fn` under `label`. For an async `fn`, only its synchronous prefix (up to its first await) is labeled; label each continuation separately. */
11
+ export declare function withLoopPhase<T>(label: string, fn: () => T): T;
12
+ /** Arm an attribution window starting at `start` (a deadline on `now`'s clock). Called with no arguments it disarms and clears. */
13
+ export declare function resetLoopPhaseWindow(start?: number, now?: () => number): void;
14
+ /** Attribute the window [start, end] and disarm it. Returns undefined when no window is armed or no label outweighs unlabeled time. */
15
+ export declare function takeLoopPhaseAttribution(end?: number): LoopPhaseAttribution | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.8.8",
3
+ "version": "18.9.0",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -57,7 +57,7 @@
57
57
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
58
58
  },
59
59
  "dependencies": {
60
- "@oh-my-pi/pi-natives": "18.8.8"
60
+ "@oh-my-pi/pi-natives": "18.9.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14"
package/src/ar/open.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { formatBytes } from "../format";
4
+ import { hasFsCode } from "../fs-error";
4
5
  import { UTF8_DECODER } from "./bytes";
5
6
  import { ArchiveError } from "./error";
6
7
  import { type ArchiveLimits, assertInMemorySize, DEFAULT_ARCHIVE_LIMITS } from "./limits";
@@ -179,17 +180,15 @@ export async function extractArchive(
179
180
  if (outputPath !== extractRoot && !outputPath.startsWith(extractRoot + path.sep)) {
180
181
  throw new ArchiveError(`Archive entry escapes extraction dir: ${entry.path}`);
181
182
  }
182
- if (entry.isDirectory) {
183
- if (entry.storage?.type !== "link") {
184
- await fs.mkdir(outputPath, { recursive: true });
185
- count++;
186
- }
187
- continue;
188
- }
189
183
  if (entry.storage?.type === "link") {
190
184
  links.push({ path: entry.path, target: entry.storage.targetPath });
191
185
  continue;
192
186
  }
187
+ if (entry.isDirectory) {
188
+ await fs.mkdir(outputPath, { recursive: true });
189
+ count++;
190
+ continue;
191
+ }
193
192
  files.push({ path: entry.path, mode: entry.mode });
194
193
  }
195
194
 
@@ -217,7 +216,29 @@ export async function extractArchive(
217
216
  throw new ArchiveError(`Archive symlink escapes extraction dir: ${link.path} -> ${link.target}`);
218
217
  }
219
218
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
220
- await fs.symlink(path.relative(path.dirname(outputPath), resolvedTarget) || ".", outputPath);
219
+ const relativeTarget = path.relative(path.dirname(outputPath), resolvedTarget) || ".";
220
+ const linkType = process.platform === "win32" && archive.getNode(link.path)?.isDirectory ? "dir" : "file";
221
+ try {
222
+ await fs.symlink(relativeTarget, outputPath, linkType);
223
+ } catch (error) {
224
+ if (process.platform !== "win32" || !hasFsCode(error, "EPERM")) throw error;
225
+ // Windows without the symlink privilege: degrade to a directory
226
+ // junction or a file copy so extraction still yields usable
227
+ // content instead of failing the whole archive.
228
+ if (linkType === "dir") {
229
+ await fs.symlink(resolvedTarget, outputPath, "junction");
230
+ } else {
231
+ // Resolve through the archive, not the extraction order: this
232
+ // link may target another link that has not materialized yet.
233
+ // A dangling target cannot be copied without the symlink
234
+ // privilege; skip the entry so the rest still extracts.
235
+ const extracted = await archive.readFile(link.path).catch(() => undefined);
236
+ if (!extracted) continue;
237
+ await Bun.write(outputPath, extracted.bytes);
238
+ const permissions = (extracted.mode ?? 0) & 0o777;
239
+ if (permissions) await fs.chmod(outputPath, permissions);
240
+ }
241
+ }
221
242
  count++;
222
243
  }
223
244
 
package/src/color.ts CHANGED
@@ -399,6 +399,12 @@ export function oklchCusp(h: number): { l: number; c: number } {
399
399
  return { l: lCusp, c: lCusp * sCusp };
400
400
  }
401
401
 
402
+ /** Angular distance between two hues in degrees (0–180), wrapping at 360. */
403
+ export function hueDistance(a: number, b: number): number {
404
+ const d = Math.abs(a - b) % 360;
405
+ return Math.min(d, 360 - d);
406
+ }
407
+
402
408
  /** Slack allowed on linear channels before a color counts as out of sRGB gamut. */
403
409
  const GAMUT_EPSILON = 1e-4;
404
410
 
package/src/json-lexer.ts CHANGED
@@ -32,6 +32,16 @@ export const LBRACKET = 0x5b;
32
32
  export const RBRACKET = 0x5d;
33
33
  const U = 0x75;
34
34
 
35
+ /**
36
+ * Minimum remaining string length for bulk-skipping ordinary characters in
37
+ * {@link JsonLexer.string} with two engine `indexOf` scans (nearest quote or
38
+ * backslash) instead of one `charCodeAt` per character. Below this the
39
+ * `indexOf` setup costs more than the per-char loop on quote-dense input;
40
+ * long string runs — the multi-KB tool-argument payloads that dominate
41
+ * streaming re-parses — scan several times faster above it.
42
+ */
43
+ const STRING_BULK_SCAN_MIN = 32;
44
+
35
45
  /** Valid chars after `\` in a strict JSON escape: `" \ / b f n r t u`. */
36
46
  export const VALID_ESCAPE_CHAR = new Uint8Array(128);
37
47
  for (const ch of '"\\/bfnrtu') VALID_ESCAPE_CHAR[ch.charCodeAt(0)] = 1;
@@ -218,7 +228,33 @@ export class JsonLexer {
218
228
  // malformed structure fails loudly instead of silently swallowing
219
229
  // commas/colons or sibling members.
220
230
  const lenient = quote === SQUOTE || this.mode === "streaming";
231
+ const quoteChar = quote === QUOTE ? '"' : "'";
232
+ // Next quote / backslash at or after the cursor, cached across iterations
233
+ // and re-searched only once the cursor passes them, so every character is
234
+ // scanned at most once per kind and the bulk path stays linear even on
235
+ // escape-dense input (re-searching the far closing quote after each
236
+ // escape would be quadratic). `n` means "none before end of input".
237
+ let nextQuote = -1;
238
+ let nextBackslash = -1;
221
239
  while (i < n) {
240
+ // Bulk-skip a run of ordinary characters: jump to the nearest quote or
241
+ // backslash instead of one `charCodeAt` per character. Only entered on
242
+ // an ordinary character, so back-to-back escapes never pay an `indexOf`;
243
+ // short tails keep the per-char loop. Landing on a special char falls
244
+ // through to the unchanged handling below, so tokens are identical.
245
+ const c0 = s.charCodeAt(i);
246
+ if (c0 !== BACKSLASH && c0 !== quote && n - i > STRING_BULK_SCAN_MIN) {
247
+ if (nextQuote < i) {
248
+ const q = s.indexOf(quoteChar, i);
249
+ nextQuote = q === -1 ? n : q;
250
+ }
251
+ if (nextBackslash < i) {
252
+ const b = s.indexOf("\\", i);
253
+ nextBackslash = b === -1 ? n : b;
254
+ }
255
+ i = nextQuote < nextBackslash ? nextQuote : nextBackslash;
256
+ if (i >= n) break;
257
+ }
222
258
  const cc = s.charCodeAt(i);
223
259
  if (cc !== BACKSLASH && cc !== quote) {
224
260
  i++;
package/src/loop-phase.ts CHANGED
@@ -1,8 +1,16 @@
1
+ /** A label and its exclusive in-window time, as named by {@link takeLoopPhaseAttribution}. */
2
+ export interface LoopPhaseAttribution {
3
+ readonly label: string;
4
+ /** Total time this label was the innermost active phase inside the late window [start, end]. */
5
+ readonly ms: number;
6
+ }
7
+
1
8
  /**
2
- * Live event-loop phase breadcrumb. Hot synchronous paths push a short label
3
- * before running and pop it after (via `try`/`finally`); the loop watchdog
4
- * reads {@link takeRecentLoopPhase} when it detects a block, so a stall is
5
- * logged with the work that caused it instead of an opaque "unknown".
9
+ * Process-global synchronous loop phases. The watchdog arms a late window and
10
+ * reads attribution via {@link takeLoopPhaseAttribution}; attribution totals
11
+ * only the innermost label's time inside that window. A label is named only
12
+ * when its total outweighs all unlabeled time, otherwise the block remains
13
+ * "unknown". Disarmed push/pop do not read the clock.
6
14
  *
7
15
  * This is deliberately a process-global stack and not part of the logger span
8
16
  * machinery: `main.ts` ends timing spans before the interactive TUI starts, so
@@ -16,19 +24,31 @@
16
24
  * work, push/pop around each synchronous chunk, not across the await.
17
25
  */
18
26
  const stack: string[] = [];
19
- // The most recent label pushed, retained after it is popped. A hot path pushes
20
- // and pops a phase entirely within one synchronous macrotask, so by the time
21
- // the watchdog's delayed tick runs the stack is already empty; this slot keeps
22
- // the culprit available for that one tick. Consumed (cleared) on read so it
23
- // only attributes the just-elapsed interval.
24
- let recentPhase: string | undefined;
27
+ const defaultClock = () => performance.now();
28
+ let windowStart = Number.POSITIVE_INFINITY;
29
+ let segmentStart = windowStart;
30
+ let clock = defaultClock;
31
+ const totals = new Map<string, number>();
32
+ let labeledMs = 0;
33
+
34
+ function boundary(t: number): void {
35
+ const ms = Math.max(0, t - Math.max(segmentStart, windowStart));
36
+ const label = stack[stack.length - 1];
37
+ if (label !== undefined && ms > 0) {
38
+ totals.set(label, (totals.get(label) ?? 0) + ms);
39
+ labeledMs += ms;
40
+ }
41
+ segmentStart = t;
42
+ }
25
43
 
26
44
  export function pushLoopPhase(label: string): void {
45
+ if (windowStart !== Number.POSITIVE_INFINITY) boundary(clock());
27
46
  stack.push(label);
28
- recentPhase = label;
29
47
  }
30
48
 
31
49
  export function popLoopPhase(): void {
50
+ if (stack.length === 0) return;
51
+ if (windowStart !== Number.POSITIVE_INFINITY) boundary(clock());
32
52
  stack.pop();
33
53
  }
34
54
 
@@ -36,14 +56,39 @@ export function currentLoopPhase(): string | undefined {
36
56
  return stack[stack.length - 1];
37
57
  }
38
58
 
39
- /**
40
- * Phase to blame for a just-detected loop block: the live top phase if one is
41
- * still held, else the most recent phase pushed since the last call. Clears the
42
- * recent slot so a block in a later, phase-less interval is not misattributed
43
- * to a phase that already finished.
44
- */
45
- export function takeRecentLoopPhase(): string | undefined {
46
- const phase = stack[stack.length - 1] ?? recentPhase;
47
- recentPhase = undefined;
48
- return phase;
59
+ /** Run `fn` under `label`. For an async `fn`, only its synchronous prefix (up to its first await) is labeled; label each continuation separately. */
60
+ export function withLoopPhase<T>(label: string, fn: () => T): T {
61
+ pushLoopPhase(label);
62
+ try {
63
+ return fn();
64
+ } finally {
65
+ popLoopPhase();
66
+ }
67
+ }
68
+
69
+ /** Arm an attribution window starting at `start` (a deadline on `now`'s clock). Called with no arguments it disarms and clears. */
70
+ export function resetLoopPhaseWindow(start = Number.POSITIVE_INFINITY, now: () => number = defaultClock): void {
71
+ windowStart = segmentStart = start;
72
+ clock = now;
73
+ totals.clear();
74
+ labeledMs = 0;
75
+ }
76
+
77
+ /** Attribute the window [start, end] and disarm it. Returns undefined when no window is armed or no label outweighs unlabeled time. */
78
+ export function takeLoopPhaseAttribution(end?: number): LoopPhaseAttribution | undefined {
79
+ if (windowStart === Number.POSITIVE_INFINITY) return undefined;
80
+ const t = end ?? clock();
81
+ boundary(t);
82
+ const unknownMs = Math.max(0, t - windowStart - labeledMs);
83
+ let winner: string | undefined;
84
+ let winnerMs = 0;
85
+ for (const [label, ms] of totals) {
86
+ if (ms >= winnerMs) {
87
+ winner = label;
88
+ winnerMs = ms;
89
+ }
90
+ }
91
+ const result = winner !== undefined && winnerMs > unknownMs ? { label: winner, ms: winnerMs } : undefined;
92
+ resetLoopPhaseWindow();
93
+ return result;
49
94
  }