@oh-my-pi/pi-utils 18.8.9 → 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 +18 -0
- package/dist/types/loop-phase.d.ts +12 -7
- package/package.json +2 -2
- package/src/ar/open.ts +29 -8
- package/src/json-lexer.ts +36 -0
- package/src/loop-phase.ts +66 -21
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
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
|
+
|
|
5
23
|
## [18.8.9] - 2026-10-10
|
|
6
24
|
|
|
7
25
|
### Added
|
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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/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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
let
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
}
|