@oh-my-pi/pi-utils 16.1.23 → 16.2.1

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,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.2.0] - 2026-06-27
6
+
7
+ ### Added
8
+
9
+ - Added a relaxed JSON parser supporting single-quoted strings, unquoted keys, and comments.
10
+ - Added `parseStreamingJson` and `parseStreamingJsonThrottled` for robust, efficient parsing of truncated or incremental streaming JSON.
11
+ - Added an XDG-aware document conversion cache directory helper.
12
+ - Exported `removeWithRetries()` as a standalone asynchronous function to handle retry-on-EBUSY cleanup logic.
13
+
14
+ ### Changed
15
+
16
+ - Improved `readSseJson` to gracefully recover truncated or malformed final events using the streaming JSON parser, ending the stream cleanly instead of throwing.
17
+ - Increased the retry delay for EBUSY file-lock errors from 25ms to 50ms (extending the total retry window to 2 seconds) to improve reliability on Windows.
18
+
5
19
  ## [16.1.8] - 2026-06-20
6
20
 
7
21
  ### Added
@@ -79,6 +79,13 @@ export declare function setAgentDir(dir: string): void;
79
79
  * no business clearing it.
80
80
  */
81
81
  export declare function __resetProfileSnapshotForTests(): void;
82
+ /**
83
+ * Test-only: rebuild profile + directory state from the current process env.
84
+ * Production code keeps the module-load profile stable; tests that mutate
85
+ * `setAgentDir`/`setProfile` need an exact restore point after they put env vars
86
+ * back.
87
+ */
88
+ export declare function __resetDirsFromEnvForTests(): void;
82
89
  /** Activate a named profile. Passing undefined or "default" returns to the default profile. */
83
90
  export declare function setProfile(profile: string | undefined): void;
84
91
  /** Get the active named profile. Undefined means the default profile. */
@@ -114,7 +121,25 @@ export declare function getPluginsPackageJson(home?: string): string;
114
121
  export declare function getPluginsLockfile(home?: string): string;
115
122
  /** Get the remote mount directory (~/.omp/remote). */
116
123
  export declare function getRemoteDir(): string;
117
- /** Get the agent-managed worktrees directory (~/.omp/wt). */
124
+ /**
125
+ * Relocate the base directory for agent-managed worktrees (PR checkouts, task
126
+ * isolation, and `omp worktree` cleanup all read the same base). Driven by the
127
+ * `worktree.base` setting in coding-agent; pass `undefined`/empty to clear and
128
+ * fall back to `OMP_WORKTREE_DIR` or the `~/.omp/wt` default.
129
+ *
130
+ * `~` is expanded and a relative path is rejected (see {@link resolveWorktreeBase}).
131
+ * Returns the absolute path that took effect, or `undefined` if the input was
132
+ * cleared or rejected — callers can warn on a non-empty input that returns
133
+ * `undefined`.
134
+ */
135
+ export declare function setWorktreesDir(dir: string | undefined): string | undefined;
136
+ /**
137
+ * Get the agent-managed worktrees directory. Resolution order: the
138
+ * `OMP_WORKTREE_DIR` env var, then the {@link setWorktreesDir} override (the
139
+ * `worktree.base` setting), then the `~/.omp/wt` default. The env var and the
140
+ * override are both `~`-expanded and must be absolute; a relative value is
141
+ * ignored and resolution falls through.
142
+ */
118
143
  export declare function getWorktreesDir(): string;
119
144
  /** Get the SSH control socket directory (~/.omp/ssh-control). */
120
145
  export declare function getSshControlDir(): string;
@@ -182,6 +207,8 @@ export declare function getHistoryDbPath(agentDir?: string): string;
182
207
  export declare function getModelDbPath(agentDir?: string): string;
183
208
  /** Get the tiny title model cache directory (~/.omp/agent/cache/tiny-models). */
184
209
  export declare function getTinyModelsCacheDir(agentDir?: string): string;
210
+ /** Get the document conversion cache directory (~/.omp/agent/cache/document-conversions; XDG default: $XDG_CACHE_HOME/omp/cache/document-conversions). */
211
+ export declare function getDocumentConversionCacheDir(agentDir?: string): string;
185
212
  /** Get the sessions directory (~/.omp/agent/sessions). */
186
213
  export declare function getSessionsDir(agentDir?: string): string;
187
214
  /** Get the content-addressed blob store directory (~/.omp/agent/blobs). */
@@ -9,6 +9,7 @@ export * from "./frontmatter";
9
9
  export * from "./fs-error";
10
10
  export * from "./glob";
11
11
  export * from "./json";
12
+ export * from "./json-parse";
12
13
  export * as logger from "./logger";
13
14
  export * from "./loop-phase";
14
15
  export * from "./mermaid-ascii";
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Lightweight string-level repair of the escape/control-char hazards that make
3
+ * otherwise-valid JSON fail `JSON.parse`: raw control characters inside strings
4
+ * are escaped, and invalid `\x` escapes have their backslash escaped. Returns the
5
+ * input unchanged when no repair is needed. Pure string→string; does not parse.
6
+ */
7
+ export declare function repairJson(json: string): string;
8
+ /**
9
+ * Final-parse a JSON value, repairing the common LLM malformations
10
+ * ({@link RelaxedJson}). Tries strict `JSON.parse` first (fast path, exact JSON
11
+ * semantics), then the relaxed parser. Throws when the input is unrepairable,
12
+ * truncated, or carries trailing garbage — so callers can skip a bad tool call
13
+ * rather than execute a half-formed one.
14
+ */
15
+ export declare function parseJsonWithRepair<T>(json: string): T;
16
+ /**
17
+ * Parse possibly-incomplete JSON during streaming. Always returns a value, never
18
+ * throws: `{}` for empty/whitespace/unrecoverable buffers, and an auto-closed
19
+ * best-effort object for truncated ones.
20
+ */
21
+ export declare function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T;
22
+ /**
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.
26
+ */
27
+ export declare const STREAMING_JSON_PARSE_MIN_GROWTH = 256;
28
+ /**
29
+ * Throttled variant of {@link parseStreamingJson} for the per-delta hot path.
30
+ *
31
+ * Tool calls arrive as a long sequence of small deltas — calling
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).
36
+ *
37
+ * Each provider tracks the last parsed length on its tool-call block, so the
38
+ * final `toolcall_end` parse (which providers already perform unconditionally)
39
+ * is the authoritative full parse — the throttle only delays mid-stream UI
40
+ * updates by at most `minGrowthBytes` of accumulated partial content.
41
+ *
42
+ * @returns the parsed object plus the new `parsedLen` to persist; or `null`
43
+ * when the buffer has not grown enough to warrant a re-parse.
44
+ */
45
+ export declare function parseStreamingJsonThrottled<T = Record<string, unknown>>(partialJson: string | undefined, lastParsedLen: number, minGrowthBytes?: number): {
46
+ value: T;
47
+ parsedLen: number;
48
+ } | null;
@@ -12,4 +12,6 @@ export declare class TempDir {
12
12
  [Symbol.asyncDispose](): Promise<void>;
13
13
  [Symbol.dispose](): void;
14
14
  }
15
+ /** Removes a path recursively, retrying transient Windows deletion failures. */
16
+ export declare function removeWithRetries(target: string): Promise<void>;
15
17
  export declare function removeSyncWithRetries(target: string): void;
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.1.23",
4
+ "version": "16.2.1",
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.1.23",
34
+ "@oh-my-pi/pi-natives": "16.2.1",
35
35
  "handlebars": "^4.7.9",
36
36
  "winston": "^3.19.0",
37
37
  "winston-daily-rotate-file": "^5.0.0"
package/src/dirs.ts CHANGED
@@ -431,6 +431,18 @@ export function __resetProfileSnapshotForTests(): void {
431
431
  );
432
432
  }
433
433
 
434
+ /**
435
+ * Test-only: rebuild profile + directory state from the current process env.
436
+ * Production code keeps the module-load profile stable; tests that mutate
437
+ * `setAgentDir`/`setProfile` need an exact restore point after they put env vars
438
+ * back.
439
+ */
440
+ export function __resetDirsFromEnvForTests(): void {
441
+ activeProfile = readProfileFromEnvSafe();
442
+ __resetProfileSnapshotForTests();
443
+ refreshDirsFromEnv();
444
+ }
445
+
434
446
  /** Activate a named profile. Passing undefined or "default" returns to the default profile. */
435
447
  export function setProfile(profile: string | undefined): void {
436
448
  const next = normalizeProfileName(profile);
@@ -540,9 +552,51 @@ export function getRemoteDir(): string {
540
552
  return dirs.rootSubdir("remote", "data");
541
553
  }
542
554
 
543
- /** Get the agent-managed worktrees directory (~/.omp/wt). */
555
+ /**
556
+ * Expand a leading `~` and require an absolute result. Returns `undefined` for
557
+ * empty/whitespace input or a path that is still relative after expansion.
558
+ *
559
+ * A worktree base is process-global and consumed by both creation
560
+ * (PR checkout, task isolation) and cleanup (`omp worktree`). A relative value
561
+ * would resolve against whatever cwd happened to launch `omp`, so checkout and
562
+ * cleanup could disagree — we refuse it rather than silently bind it to cwd.
563
+ */
564
+ function resolveWorktreeBase(value: string | undefined): string | undefined {
565
+ const trimmed = value?.trim();
566
+ if (!trimmed) return undefined;
567
+ let p = trimmed;
568
+ if (p === "~") p = os.homedir();
569
+ else if (p.startsWith("~/") || p.startsWith("~\\")) p = os.homedir() + p.slice(1);
570
+ return path.isAbsolute(p) ? path.normalize(p) : undefined;
571
+ }
572
+
573
+ let worktreesDirOverride: string | undefined;
574
+
575
+ /**
576
+ * Relocate the base directory for agent-managed worktrees (PR checkouts, task
577
+ * isolation, and `omp worktree` cleanup all read the same base). Driven by the
578
+ * `worktree.base` setting in coding-agent; pass `undefined`/empty to clear and
579
+ * fall back to `OMP_WORKTREE_DIR` or the `~/.omp/wt` default.
580
+ *
581
+ * `~` is expanded and a relative path is rejected (see {@link resolveWorktreeBase}).
582
+ * Returns the absolute path that took effect, or `undefined` if the input was
583
+ * cleared or rejected — callers can warn on a non-empty input that returns
584
+ * `undefined`.
585
+ */
586
+ export function setWorktreesDir(dir: string | undefined): string | undefined {
587
+ worktreesDirOverride = resolveWorktreeBase(dir);
588
+ return worktreesDirOverride;
589
+ }
590
+
591
+ /**
592
+ * Get the agent-managed worktrees directory. Resolution order: the
593
+ * `OMP_WORKTREE_DIR` env var, then the {@link setWorktreesDir} override (the
594
+ * `worktree.base` setting), then the `~/.omp/wt` default. The env var and the
595
+ * override are both `~`-expanded and must be absolute; a relative value is
596
+ * ignored and resolution falls through.
597
+ */
544
598
  export function getWorktreesDir(): string {
545
- return dirs.rootSubdir("wt", "data");
599
+ return resolveWorktreeBase(process.env.OMP_WORKTREE_DIR) ?? worktreesDirOverride ?? dirs.rootSubdir("wt", "data");
546
600
  }
547
601
 
548
602
  /** Get the SSH control socket directory (~/.omp/ssh-control). */
@@ -693,6 +747,11 @@ export function getTinyModelsCacheDir(agentDir?: string): string {
693
747
  return dirs.agentSubdir(agentDir, path.join("cache", "tiny-models"), "cache");
694
748
  }
695
749
 
750
+ /** Get the document conversion cache directory (~/.omp/agent/cache/document-conversions; XDG default: $XDG_CACHE_HOME/omp/cache/document-conversions). */
751
+ export function getDocumentConversionCacheDir(agentDir?: string): string {
752
+ return dirs.agentSubdir(agentDir, path.join("cache", "document-conversions"), "cache");
753
+ }
754
+
696
755
  /** Get the sessions directory (~/.omp/agent/sessions). */
697
756
  export function getSessionsDir(agentDir?: string): string {
698
757
  return dirs.agentSubdir(agentDir, "sessions", "data");
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from "./frontmatter";
9
9
  export * from "./fs-error";
10
10
  export * from "./glob";
11
11
  export * from "./json";
12
+ export * from "./json-parse";
12
13
  export * as logger from "./logger";
13
14
  export * from "./loop-phase";
14
15
  export * from "./mermaid-ascii";
@@ -0,0 +1,558 @@
1
+ const QUOTE = 0x22;
2
+ const BACKSLASH = 0x5c;
3
+ const U = 0x75;
4
+ const SQUOTE = 0x27;
5
+
6
+ // Valid chars after `\`: " \ / b f n r t u
7
+ const VALID_ESCAPE_CHAR = new Uint8Array(128);
8
+ for (const ch of '"\\/bfnrtu') VALID_ESCAPE_CHAR[ch.charCodeAt(0)] = 1;
9
+
10
+ const CONTROL_ESCAPES: readonly string[] = (() => {
11
+ const e: string[] = [];
12
+ e[0x08] = "\\b";
13
+ e[0x09] = "\\t";
14
+ e[0x0a] = "\\n";
15
+ e[0x0c] = "\\f";
16
+ e[0x0d] = "\\r";
17
+ for (let cp = 0; cp <= 0x1f; cp++) {
18
+ e[cp] ??= `\\u${cp.toString(16).padStart(4, "0")}`;
19
+ }
20
+ return e;
21
+ })();
22
+
23
+ const HEX4_RE = /^[0-9a-fA-F]{4}$/;
24
+
25
+ function isHexDigit(cp: number): boolean {
26
+ return (cp >= 0x30 && cp <= 0x39) || ((cp | 0x20) >= 0x61 && (cp | 0x20) <= 0x66);
27
+ }
28
+
29
+ function isWhitespace(cp: number): boolean {
30
+ return cp === 0x20 || cp === 0x09 || cp === 0x0a || cp === 0x0d;
31
+ }
32
+
33
+ function isIdentChar(cp: number): boolean {
34
+ return (
35
+ (cp >= 0x30 && cp <= 0x39) ||
36
+ ((cp | 0x20) >= 0x61 && (cp | 0x20) <= 0x7a) ||
37
+ cp === 0x5f /* _ */ ||
38
+ cp === 0x24 /* $ */
39
+ );
40
+ }
41
+
42
+ /** Bareword literals: standard JSON plus Python `True`/`False`/`None`. */
43
+ const KEYWORDS: readonly (readonly [string, unknown])[] = [
44
+ ["true", true],
45
+ ["false", false],
46
+ ["null", null],
47
+ ["True", true],
48
+ ["False", false],
49
+ ["None", null],
50
+ ];
51
+
52
+ /**
53
+ * Sentinel returned by partial-mode value parsing when an atomic value
54
+ * (number / keyword) is incomplete at the streaming edge, so the enclosing
55
+ * object/array rolls back to the last valid prefix instead of committing junk.
56
+ */
57
+ const INCOMPLETE = Symbol("incomplete");
58
+
59
+ /**
60
+ * Lightweight string-level repair of the escape/control-char hazards that make
61
+ * otherwise-valid JSON fail `JSON.parse`: raw control characters inside strings
62
+ * are escaped, and invalid `\x` escapes have their backslash escaped. Returns the
63
+ * input unchanged when no repair is needed. Pure string→string; does not parse.
64
+ */
65
+ export function repairJson(json: string): string {
66
+ const len = json.length;
67
+ const parts: string[] = [];
68
+ let lastEmit = 0;
69
+ let inString = false;
70
+ let i = 0;
71
+
72
+ while (i < len) {
73
+ if (!inString) {
74
+ // Fast scan: skip to next quote.
75
+ while (i < len && json.charCodeAt(i) !== QUOTE) i++;
76
+ if (i >= len) break;
77
+ inString = true;
78
+ i++;
79
+ continue;
80
+ }
81
+
82
+ // Fast scan inside string: advance past chars that need no handling.
83
+ while (i < len) {
84
+ const cp = json.charCodeAt(i);
85
+ if (cp < 0x20 || cp === QUOTE || cp === BACKSLASH) break;
86
+ i++;
87
+ }
88
+ if (i >= len) break;
89
+
90
+ const cp = json.charCodeAt(i);
91
+
92
+ if (cp === QUOTE) {
93
+ inString = false;
94
+ i++;
95
+ continue;
96
+ }
97
+
98
+ if (cp === BACKSLASH) {
99
+ // Need at least one char after the backslash; treat EOI as invalid escape.
100
+ if (i + 1 >= len) {
101
+ parts.push(json.slice(lastEmit, i), "\\\\");
102
+ lastEmit = i + 1;
103
+ i++;
104
+ continue;
105
+ }
106
+
107
+ const nextCp = json.charCodeAt(i + 1);
108
+
109
+ if (nextCp === U) {
110
+ // Need full \uXXXX, all four digits, all hex.
111
+ if (
112
+ i + 5 < len &&
113
+ isHexDigit(json.charCodeAt(i + 2)) &&
114
+ isHexDigit(json.charCodeAt(i + 3)) &&
115
+ isHexDigit(json.charCodeAt(i + 4)) &&
116
+ isHexDigit(json.charCodeAt(i + 5))
117
+ ) {
118
+ i += 6;
119
+ continue;
120
+ }
121
+ // Truncated or non-hex \u — escape the backslash, re-process the rest.
122
+ parts.push(json.slice(lastEmit, i), "\\\\");
123
+ lastEmit = i + 1;
124
+ i++;
125
+ continue;
126
+ }
127
+
128
+ if (nextCp < 128 && VALID_ESCAPE_CHAR[nextCp] === 1) {
129
+ i += 2;
130
+ continue;
131
+ }
132
+
133
+ parts.push(json.slice(lastEmit, i), "\\\\");
134
+ lastEmit = i + 1;
135
+ i++;
136
+ continue;
137
+ }
138
+
139
+ // Control character (cp < 0x20).
140
+ parts.push(json.slice(lastEmit, i), CONTROL_ESCAPES[cp]);
141
+ lastEmit = i + 1;
142
+ i++;
143
+ }
144
+
145
+ if (!parts.length) return json;
146
+ if (lastEmit < len) parts.push(json.slice(lastEmit));
147
+ return parts.join("");
148
+ }
149
+
150
+ /**
151
+ * Recursive-descent parser for a forgiving superset of JSON. Beyond strict JSON
152
+ * it accepts, and normalizes, the malformations LLM tool-call bodies leak in
153
+ * practice:
154
+ *
155
+ * - single-quoted strings and unquoted object keys (JSON5);
156
+ * - trailing / stray commas, and `//` + block comments;
157
+ * - Python literals `True` / `False` / `None` and JS `NaN` / `Infinity`;
158
+ * - raw control characters and invalid `\x` escapes inside strings (kept literally);
159
+ * - unescaped quotes inside strings — a quote only closes a string when followed
160
+ * by a value terminator, recovering apostrophes such as `'it's'`.
161
+ *
162
+ * In `partial` mode an unterminated string/object/array (or a value cut off at
163
+ * end-of-input) is auto-closed with whatever was parsed so far — for streaming.
164
+ * In strict mode, end-of-input mid-value and trailing garbage both throw, so a
165
+ * final parse never silently accepts a half-formed tool call.
166
+ */
167
+ class RelaxedJson {
168
+ readonly #s: string;
169
+ readonly #n: number;
170
+ readonly #partial: boolean;
171
+ #i = 0;
172
+
173
+ constructor(source: string, partial: boolean) {
174
+ this.#s = source;
175
+ this.#n = source.length;
176
+ this.#partial = partial;
177
+ }
178
+
179
+ parse(): unknown {
180
+ this.#ws();
181
+ if (this.#i >= this.#n) {
182
+ if (this.#partial) return undefined;
183
+ throw new SyntaxError("Unexpected end of JSON input");
184
+ }
185
+ const value = this.#value();
186
+ if (value === INCOMPLETE) return undefined;
187
+ this.#ws();
188
+ if (!this.#partial && this.#i < this.#n) {
189
+ throw new SyntaxError(`Unexpected trailing characters at position ${this.#i}`);
190
+ }
191
+ return value;
192
+ }
193
+
194
+ #ws(): void {
195
+ const s = this.#s;
196
+ for (;;) {
197
+ while (this.#i < this.#n && isWhitespace(s.charCodeAt(this.#i))) this.#i++;
198
+ if (this.#i + 1 < this.#n && s.charCodeAt(this.#i) === 0x2f /* / */) {
199
+ const next = s.charCodeAt(this.#i + 1);
200
+ if (next === 0x2f /* / line comment */) {
201
+ this.#i += 2;
202
+ while (this.#i < this.#n && s.charCodeAt(this.#i) !== 0x0a) this.#i++;
203
+ continue;
204
+ }
205
+ if (next === 0x2a /* * block comment */) {
206
+ this.#i += 2;
207
+ while (
208
+ this.#i + 1 < this.#n &&
209
+ !(s.charCodeAt(this.#i) === 0x2a && s.charCodeAt(this.#i + 1) === 0x2f)
210
+ ) {
211
+ this.#i++;
212
+ }
213
+ this.#i = Math.min(this.#i + 2, this.#n);
214
+ continue;
215
+ }
216
+ }
217
+ break;
218
+ }
219
+ }
220
+
221
+ #value(): unknown {
222
+ const s = this.#s;
223
+ const c = s[this.#i];
224
+ if (c === "{") return this.#object();
225
+ if (c === "[") return this.#array();
226
+ if (c === '"' || c === "'") return this.#string(s.charCodeAt(this.#i));
227
+ const cc = s.charCodeAt(this.#i);
228
+ if (cc === 0x2d /* - */ || cc === 0x2b /* + */ || cc === 0x2e /* . */ || (cc >= 0x30 && cc <= 0x39)) {
229
+ // JS-only NaN / Infinity are deliberately not accepted: a tool must not
230
+ // execute with a non-finite numeric arg; they fall through #number's
231
+ // NaN guard (strict throw / partial rollback) like other bad tokens.
232
+ return this.#number();
233
+ }
234
+ return this.#keyword();
235
+ }
236
+
237
+ #object(): Record<string, unknown> {
238
+ this.#i++; // consume {
239
+ const out: Record<string, unknown> = {};
240
+ for (;;) {
241
+ this.#ws();
242
+ if (this.#i >= this.#n) {
243
+ if (this.#partial) return out;
244
+ throw new SyntaxError("Unterminated object");
245
+ }
246
+ const c = this.#s[this.#i];
247
+ if (c === "}") {
248
+ this.#i++;
249
+ return out;
250
+ }
251
+ if (c === ",") {
252
+ // Tolerate leading / doubled / trailing commas.
253
+ this.#i++;
254
+ continue;
255
+ }
256
+ const key = this.#key();
257
+ this.#ws();
258
+ if (this.#i < this.#n && this.#s[this.#i] === ":") {
259
+ this.#i++;
260
+ } else if (this.#partial) {
261
+ return out;
262
+ } else {
263
+ throw new SyntaxError("Expected ':' in object");
264
+ }
265
+ this.#ws();
266
+ if (this.#i >= this.#n) {
267
+ if (this.#partial) return out;
268
+ throw new SyntaxError("Expected value after ':'");
269
+ }
270
+ const value = this.#value();
271
+ if (value === INCOMPLETE) return out;
272
+ out[key] = value;
273
+ this.#ws();
274
+ const d = this.#i < this.#n ? this.#s[this.#i] : "";
275
+ if (d === ",") {
276
+ this.#i++;
277
+ continue;
278
+ }
279
+ if (d === "}") {
280
+ this.#i++;
281
+ return out;
282
+ }
283
+ if (this.#partial) return out;
284
+ throw new SyntaxError("Expected ',' or '}' in object");
285
+ }
286
+ }
287
+
288
+ #array(): unknown[] {
289
+ this.#i++; // consume [
290
+ const out: unknown[] = [];
291
+ for (;;) {
292
+ this.#ws();
293
+ if (this.#i >= this.#n) {
294
+ if (this.#partial) return out;
295
+ throw new SyntaxError("Unterminated array");
296
+ }
297
+ const c = this.#s[this.#i];
298
+ if (c === "]") {
299
+ this.#i++;
300
+ return out;
301
+ }
302
+ if (c === ",") {
303
+ this.#i++;
304
+ continue;
305
+ }
306
+ const value = this.#value();
307
+ if (value === INCOMPLETE) return out;
308
+ out.push(value);
309
+ this.#ws();
310
+ const d = this.#i < this.#n ? this.#s[this.#i] : "";
311
+ if (d === ",") {
312
+ this.#i++;
313
+ continue;
314
+ }
315
+ if (d === "]") {
316
+ this.#i++;
317
+ return out;
318
+ }
319
+ if (this.#partial) return out;
320
+ throw new SyntaxError("Expected ',' or ']' in array");
321
+ }
322
+ }
323
+
324
+ #key(): string {
325
+ const c = this.#s[this.#i];
326
+ if (c === '"' || c === "'") return this.#string(this.#s.charCodeAt(this.#i));
327
+ // Unquoted identifier key: read until a structural delimiter / whitespace.
328
+ const start = this.#i;
329
+ while (this.#i < this.#n) {
330
+ const ch = this.#s[this.#i];
331
+ if (ch === ":" || ch === "," || ch === "}" || isWhitespace(this.#s.charCodeAt(this.#i))) break;
332
+ this.#i++;
333
+ }
334
+ if (this.#i === start) {
335
+ if (this.#partial) return "";
336
+ throw new SyntaxError("Expected object key");
337
+ }
338
+ return this.#s.slice(start, this.#i);
339
+ }
340
+
341
+ #string(quote: number): string {
342
+ const s = this.#s;
343
+ const n = this.#n;
344
+ let i = this.#i + 1; // skip opening quote
345
+ let out = "";
346
+ let runStart = i;
347
+ while (i < n) {
348
+ const cc = s.charCodeAt(i);
349
+ if (cc !== BACKSLASH && cc !== quote) {
350
+ i++;
351
+ continue;
352
+ }
353
+ if (cc === quote) {
354
+ // Apostrophe / inner-quote recovery (a quote that isn't followed by a
355
+ // value terminator is literal) is safe for single quotes and in partial
356
+ // mode. For double quotes in strict mode, close on the first unescaped
357
+ // quote like standard JSON so malformed structure fails loudly instead
358
+ // of silently swallowing commas/colons into one string.
359
+ const lenient = quote === SQUOTE || this.#partial;
360
+ if (!lenient || this.#closesString(i + 1)) {
361
+ out += s.slice(runStart, i);
362
+ this.#i = i + 1;
363
+ return out;
364
+ }
365
+ // Unescaped inner quote (e.g. apostrophe in `'it's'`) — keep it literal.
366
+ i++;
367
+ continue;
368
+ }
369
+ // Backslash escape.
370
+ out += s.slice(runStart, i);
371
+ i++;
372
+ if (i >= n) {
373
+ out += "\\";
374
+ runStart = i;
375
+ break;
376
+ }
377
+ const esc = s.charCodeAt(i);
378
+ switch (esc) {
379
+ case QUOTE:
380
+ out += '"';
381
+ break;
382
+ case SQUOTE:
383
+ out += "'";
384
+ break;
385
+ case BACKSLASH:
386
+ out += "\\";
387
+ break;
388
+ case 0x2f:
389
+ out += "/";
390
+ break;
391
+ case 0x62:
392
+ out += "\b";
393
+ break;
394
+ case 0x66:
395
+ out += "\f";
396
+ break;
397
+ case 0x6e:
398
+ out += "\n";
399
+ break;
400
+ case 0x72:
401
+ out += "\r";
402
+ break;
403
+ case 0x74:
404
+ out += "\t";
405
+ break;
406
+ case U: {
407
+ const hex = s.slice(i + 1, i + 5);
408
+ if (HEX4_RE.test(hex)) {
409
+ out += String.fromCharCode(parseInt(hex, 16));
410
+ i += 4;
411
+ } else {
412
+ out += "\\u"; // invalid \u — keep literal
413
+ }
414
+ break;
415
+ }
416
+ default:
417
+ out += `\\${s[i]}`; // invalid escape — keep backslash literal
418
+ }
419
+ i++;
420
+ runStart = i;
421
+ }
422
+ out += s.slice(runStart, i);
423
+ if (this.#partial) {
424
+ this.#i = i;
425
+ return out;
426
+ }
427
+ throw new SyntaxError("Unterminated string");
428
+ }
429
+
430
+ /** A quote closes a string only when the next non-space char ends a value. */
431
+ #closesString(from: number): boolean {
432
+ const s = this.#s;
433
+ let k = from;
434
+ while (k < this.#n && isWhitespace(s.charCodeAt(k))) k++;
435
+ if (k >= this.#n) return true;
436
+ const c = s[k];
437
+ return c === "," || c === "}" || c === "]" || c === ":";
438
+ }
439
+
440
+ #number(): unknown {
441
+ const s = this.#s;
442
+ const start = this.#i;
443
+ while (this.#i < this.#n) {
444
+ const ch = s[this.#i];
445
+ if (
446
+ (ch >= "0" && ch <= "9") ||
447
+ ch === "-" ||
448
+ ch === "+" ||
449
+ ch === "." ||
450
+ ch === "e" ||
451
+ ch === "E" ||
452
+ ch === "x" ||
453
+ ch === "X" ||
454
+ (ch >= "a" && ch <= "f") ||
455
+ (ch >= "A" && ch <= "F")
456
+ ) {
457
+ this.#i++;
458
+ } else {
459
+ break;
460
+ }
461
+ }
462
+ const token = s.slice(start, this.#i);
463
+ const num = Number(token);
464
+ if (Number.isNaN(num)) {
465
+ if (this.#partial) return INCOMPLETE;
466
+ throw new SyntaxError(`Invalid number: ${token}`);
467
+ }
468
+ return num;
469
+ }
470
+
471
+ #keyword(): unknown {
472
+ const s = this.#s;
473
+ const i = this.#i;
474
+ for (const [word, value] of KEYWORDS) {
475
+ // Require a non-identifier boundary so `Truex` / `nullish` are not misread
476
+ // as the keyword followed by junk.
477
+ if (s.startsWith(word, i) && !isIdentChar(s.charCodeAt(i + word.length))) {
478
+ this.#i += word.length;
479
+ return value;
480
+ }
481
+ }
482
+ if (this.#partial) {
483
+ // Incomplete / unrecognized atomic token at the streaming edge — signal the
484
+ // caller to roll back to the last valid prefix instead of committing junk.
485
+ this.#i = this.#n;
486
+ return INCOMPLETE;
487
+ }
488
+ throw new SyntaxError(`Unexpected token at position ${this.#i}`);
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Final-parse a JSON value, repairing the common LLM malformations
494
+ * ({@link RelaxedJson}). Tries strict `JSON.parse` first (fast path, exact JSON
495
+ * semantics), then the relaxed parser. Throws when the input is unrepairable,
496
+ * truncated, or carries trailing garbage — so callers can skip a bad tool call
497
+ * rather than execute a half-formed one.
498
+ */
499
+ export function parseJsonWithRepair<T>(json: string): T {
500
+ try {
501
+ return JSON.parse(json) as T;
502
+ } catch {
503
+ return new RelaxedJson(json, false).parse() as T;
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Parse possibly-incomplete JSON during streaming. Always returns a value, never
509
+ * throws: `{}` for empty/whitespace/unrecoverable buffers, and an auto-closed
510
+ * best-effort object for truncated ones.
511
+ */
512
+ export function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T {
513
+ const trimmed = partialJson?.trimStart();
514
+ if (!trimmed) return {} as T;
515
+ try {
516
+ return JSON.parse(trimmed) as T;
517
+ } catch {
518
+ try {
519
+ return (new RelaxedJson(trimmed, true).parse() ?? {}) as T;
520
+ } catch {
521
+ return {} as T;
522
+ }
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Default minimum byte growth before `parseStreamingJsonThrottled` will
528
+ * re-parse a streaming tool-call argument buffer. Bounds the mid-stream
529
+ * partial-parse cost from quadratic to linear in N.
530
+ */
531
+ export const STREAMING_JSON_PARSE_MIN_GROWTH = 256;
532
+
533
+ /**
534
+ * Throttled variant of {@link parseStreamingJson} for the per-delta hot path.
535
+ *
536
+ * Tool calls arrive as a long sequence of small deltas — calling
537
+ * `parseStreamingJson(buffer)` on every delta re-parses the entire buffer
538
+ * each time, giving O(N²) work in the total buffer length. Throttling skips
539
+ * the re-parse until at least `minGrowthBytes` of new content has arrived
540
+ * since the last successful parse, bounding mid-stream cost to O(N).
541
+ *
542
+ * Each provider tracks the last parsed length on its tool-call block, so the
543
+ * final `toolcall_end` parse (which providers already perform unconditionally)
544
+ * is the authoritative full parse — the throttle only delays mid-stream UI
545
+ * updates by at most `minGrowthBytes` of accumulated partial content.
546
+ *
547
+ * @returns the parsed object plus the new `parsedLen` to persist; or `null`
548
+ * when the buffer has not grown enough to warrant a re-parse.
549
+ */
550
+ export function parseStreamingJsonThrottled<T = Record<string, unknown>>(
551
+ partialJson: string | undefined,
552
+ lastParsedLen: number,
553
+ minGrowthBytes: number = STREAMING_JSON_PARSE_MIN_GROWTH,
554
+ ): { value: T; parsedLen: number } | null {
555
+ const len = partialJson?.length ?? 0;
556
+ if (len === 0 || (lastParsedLen > 0 && len - lastParsedLen < minGrowthBytes)) return null;
557
+ return { value: parseStreamingJson<T>(partialJson), parsedLen: len };
558
+ }
package/src/stream.ts CHANGED
@@ -1,4 +1,7 @@
1
+ const trailingEvents = new WeakSet<ServerSentEvent>();
2
+
1
3
  import { abortableSource } from "./abortable";
4
+ import { parseStreamingJson } from "./json-parse";
2
5
 
3
6
  const LF = 0x0a;
4
7
  type JsonlChunkResult = {
@@ -210,19 +213,39 @@ function notifySseEventObserver(observer: SseEventObserver | undefined, event: S
210
213
  }
211
214
  }
212
215
 
216
+ function isRecoverableTrailingJson(data: string): boolean {
217
+ const first = data.trimStart()[0];
218
+ if (first !== "{" && first !== "[") return false;
219
+ // Best-effort relaxed recovery via the shared streaming JSON parser: a
220
+ // container-shaped final event that fails strict `JSON.parse` is treated as a
221
+ // cut-off (or lightly malformed) stream tail and ends iteration cleanly instead
222
+ // of throwing. Non-container final events (plain-text errors, bare scalars) are
223
+ // not recoverable and still surface as a SyntaxError.
224
+ const recovered = parseStreamingJson<unknown>(data);
225
+ return typeof recovered === "object" && recovered !== null;
226
+ }
227
+
213
228
  export async function* readSseJson<T>(
214
229
  stream: ReadableStream<Uint8Array>,
215
230
  signal?: AbortSignal,
216
231
  onEvent?: SseEventObserver,
217
232
  ): AsyncGenerator<T> {
218
233
  for await (const sse of readSseEvents(stream, signal)) {
234
+ const isTrailing = trailingEvents.has(sse);
219
235
  notifySseEventObserver(onEvent, sse);
220
236
  const data = sse.data;
221
237
  if (data === "" || data === "[DONE]") {
222
238
  if (data === "[DONE]") return;
223
239
  continue;
224
240
  }
225
- yield JSON.parse(data) as T;
241
+ try {
242
+ yield JSON.parse(data) as T;
243
+ } catch (err) {
244
+ if (err instanceof SyntaxError && isTrailing && isRecoverableTrailingJson(data)) {
245
+ return;
246
+ }
247
+ throw err;
248
+ }
226
249
  }
227
250
  }
228
251
 
@@ -353,12 +376,18 @@ export async function* readSseEvents(
353
376
  if (tail) {
354
377
  lineBuffer.clear();
355
378
  const event = pushSseLine(tail, state);
356
- if (event) yield event;
379
+ if (event) {
380
+ trailingEvents.add(event);
381
+ yield event;
382
+ }
357
383
  }
358
384
  }
359
385
  // Real services don't always close on a blank line — flush any pending event.
360
386
  const trailing = flushSseEvent(state);
361
- if (trailing) yield trailing;
387
+ if (trailing) {
388
+ trailingEvents.add(trailing);
389
+ yield trailing;
390
+ }
362
391
  } catch (err) {
363
392
  if (signal?.aborted) return;
364
393
  throw err;
package/src/temp.ts CHANGED
@@ -79,11 +79,15 @@ function normalizePrefix(prefix?: string): string {
79
79
 
80
80
  const kRemoveOptions = { recursive: true, force: true } as const;
81
81
  const kRemoveRetries = 40;
82
- const kRemoveRetryDelayMs = 25;
82
+ // 50ms × 40 retries = 2s total retry window. Windows holds file locks on
83
+ // SQLite DBs for up to ~1.5s after close(); the previous 25ms (1s total)
84
+ // was too short for some test cleanup scenarios.
85
+ const kRemoveRetryDelayMs = 50;
83
86
  const kRetryableRemoveErrorCodes = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]);
84
87
  const kSleepBuffer = new Int32Array(new SharedArrayBuffer(4));
85
88
 
86
- async function removeWithRetries(target: string): Promise<void> {
89
+ /** Removes a path recursively, retrying transient Windows deletion failures. */
90
+ export async function removeWithRetries(target: string): Promise<void> {
87
91
  for (let attempt = 0; ; attempt++) {
88
92
  try {
89
93
  await fsPromises.rm(target, kRemoveOptions);