@oh-my-pi/pi-utils 18.1.5 → 18.1.8

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,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.1.7] - 2026-09-03
6
+
7
+ ### Added
8
+
9
+ - Added the public `getTinyWorkerRuntimeDir()` utility, which returns the standard `~/.omp/run/tiny` directory for tiny-worker runtime data.
10
+
11
+ ## [18.1.6] - 2026-09-03
12
+
13
+ ### Added
14
+
15
+ - Added `IncomingDoc` (`@oh-my-pi/pi-utils/incoming-json`) for incrementally reading path-addressed JSON data as text arrives, including string chunks and lines, array elements, and keyed object values, with structured errors for missing, incomplete, aborted, malformed, or mismatched data.
16
+ - Added `Serial` for running asynchronous operations sequentially in call order.
17
+
18
+ ### Fixed
19
+
20
+ - Fixed relaxed JSON parsing for single-quoted strings followed by line or block comments.
21
+
5
22
  ## [18.1.5] - 2026-09-03
6
23
 
7
24
  ### Added
@@ -20,3 +20,13 @@ export declare class AsyncDrain<T> {
20
20
  /** Runs the pending batch handler immediately and returns its completion promise. */
21
21
  flush(): Promise<void>;
22
22
  }
23
+ /**
24
+ * Runs async operations one at a time in call order. Each `run` starts after
25
+ * the previous operation settles (success or failure) and returns that
26
+ * operation's own promise, so a rejected step never poisons the queue. Used by
27
+ * stateful cursors whose concurrent pulls must not interleave.
28
+ */
29
+ export declare class Serial {
30
+ #private;
31
+ run<T>(op: () => Promise<T>): Promise<T>;
32
+ }
@@ -273,6 +273,8 @@ export declare function getCrashLogPath(agentDir?: string): string;
273
273
  export declare function getDebugLogPath(agentDir?: string): string;
274
274
  /** Get the secret placeholder key path (~/.omp/agent/secret-placeholder.key; XDG default: $XDG_STATE_HOME/omp/secret-placeholder.key). Adopts a legacy key on first XDG resolution. */
275
275
  export declare function getSecretPlaceholderKeyPath(): string;
276
+ /** Directory holding the per-model tiny-worker sockets and logs (~/.omp/run/tiny; XDG default: $XDG_STATE_HOME/omp/run/tiny). */
277
+ export declare function getTinyWorkerRuntimeDir(): string;
276
278
  /** Root directory containing every per-project daemon runtime scope (~/.omp/run/daemons; XDG default: $XDG_STATE_HOME/omp/run/daemons). */
277
279
  export declare function getDaemonRuntimeRoot(): string;
278
280
  /** Get the daemon runtime directory for a project (~/.omp/run/daemons/<hash>; XDG default: $XDG_STATE_HOME/omp/run/daemons/<hash>). */
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Typed cursors over a JSON document while its text is still arriving.
3
+ *
4
+ * {@link IncomingDoc.channel} returns a push-side {@link IncomingFeed} and a
5
+ * read-side {@link IncomingDoc}. The producer appends text fragments, then
6
+ * explicitly calls {@link IncomingFeed.finish} or {@link IncomingFeed.abort};
7
+ * a feed that is never closed leaves every pending pull waiting forever.
8
+ * There is one shared append-only buffer; cursors are cheap, immutable
9
+ * path handles over it, and every pull is an ordinary promise that
10
+ * re-scans the buffer whenever the feed changes. There are no snapshots,
11
+ * per-field events, or fan-out channels.
12
+ *
13
+ * A scalar completes at its closing quote/delimiter, and a container
14
+ * completes only when its closing delimiter arrives. Finished-but-truncated
15
+ * input rejects with kind `incomplete`; abandoned input rejects with
16
+ * `aborted`. String chunks contain only decoded text whose meaning is
17
+ * stable, so an escape or Unicode escape may span any number of fragments.
18
+ *
19
+ * Pulling an {@link IncomingObject.key} makes that key required: a missing
20
+ * or mistyped value is a structured {@link IncomingJsonError}. Object members
21
+ * never pulled are skipped without validation. {@link IncomingDoc.whole} is
22
+ * the explicit whole-document pull and runs only after successful input
23
+ * completion.
24
+ *
25
+ * Object cursors bind the first occurrence of a duplicate key, whereas
26
+ * complete-value pulls (`value()`, `collect()`, `whole()`) go through the
27
+ * final parser (`parseJsonWithRepair`), whose objects are last-write-wins.
28
+ *
29
+ * Mid-stream cursors tolerate incomplete tokens but read double-quoted
30
+ * strings with the final parser's strict closing rule: an unescaped inner
31
+ * `"` can never swallow a sibling key or value. A pulled scalar completes
32
+ * only once a value terminator follows it, like numbers, so structural
33
+ * garbage after a value surfaces as `incomplete` rather than a silently
34
+ * misparsed pull. Single-quote recovery (`'it's'`) is shared with the final
35
+ * parser and passes both.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * const { feed, doc } = IncomingDoc.channel();
40
+ * const args = doc.root().object();
41
+ * const content = args.key("content").string();
42
+ * feed.push('{"path":"a.ts","content":"hel');
43
+ * await content.nextChunk(); // "hel"
44
+ * feed.push('lo"}');
45
+ * feed.finish();
46
+ * await content.nextChunk(); // "lo"
47
+ * await content.nextChunk(); // undefined
48
+ * await args.key("path").value<string>(); // "a.ts"
49
+ * ```
50
+ */
51
+ /** Location component in a pulled JSON path: an object member name or an array index. */
52
+ export type PullPathSegment = string | number;
53
+ /** JSON shape observed by a started pull. */
54
+ export type IncomingValueKind = "null" | "boolean" | "number" | "string" | "array" | "object";
55
+ /** Why a pull could not produce the requested shape. */
56
+ export type PullIssueKind =
57
+ /** The requested member was absent when its container completed. */
58
+ "missing"
59
+ /** The producer finished before the pulled value's closing token. */
60
+ | "incomplete"
61
+ /** The producer abandoned the input before the pull completed. */
62
+ | "aborted"
63
+ /** A complete pulled value could not be parsed. */
64
+ | "malformed"
65
+ /** A value was present with a different JSON shape (see `found`). */
66
+ | "mismatch";
67
+ /** Structured failure while awaiting an incoming JSON value. */
68
+ export declare class IncomingJsonError extends Error {
69
+ /** Full key/index path pulled by the consumer. */
70
+ readonly path: readonly PullPathSegment[];
71
+ /** Shape requested by the typed cursor. */
72
+ readonly expected: string;
73
+ /** Why the pull could not produce that shape. */
74
+ readonly kind: PullIssueKind;
75
+ /** Shape observed in the input when `kind` is `mismatch`. */
76
+ readonly found?: string;
77
+ constructor(path: readonly PullPathSegment[], expected: string, kind: PullIssueKind, options?: {
78
+ found?: string;
79
+ cause?: unknown;
80
+ });
81
+ }
82
+ type Shape = {
83
+ kind: "null" | "array" | "object";
84
+ } | {
85
+ kind: "boolean";
86
+ value: boolean;
87
+ } | {
88
+ kind: "number";
89
+ value: number;
90
+ } | {
91
+ kind: "string";
92
+ value: string;
93
+ stableLen: number;
94
+ };
95
+ interface Located {
96
+ tag: "located";
97
+ /** Source offset of the value's first char. */
98
+ start: number;
99
+ /** Source offset just past the value's closing token; `undefined` while it is still open. */
100
+ end: number | undefined;
101
+ shape: Shape;
102
+ }
103
+ /** Readiness predicate a pull waits for once its value has been located. */
104
+ type Ready = (located: Located) => boolean;
105
+ /** Append-only buffer, terminal state, and change notification shared by one feed and its cursors. */
106
+ declare class Shared {
107
+ #private;
108
+ text: string;
109
+ end: "open" | "finished" | "aborted";
110
+ /** Resolves once the buffer or terminal state changes after this call. */
111
+ get changed(): Promise<void>;
112
+ notify(): void;
113
+ /** Await input completion; rejects with `aborted` when the feed was abandoned. */
114
+ finished(): Promise<void>;
115
+ /**
116
+ * Await the value at `path` until `ready` accepts it. Resolves `undefined`
117
+ * when the value's container completed without it.
118
+ */
119
+ pull(path: readonly PullPathSegment[], expected: string, ready: Ready): Promise<Located | undefined>;
120
+ }
121
+ /**
122
+ * Push side of an {@link IncomingDoc} channel. Call {@link finish} to mark
123
+ * the document complete or {@link abort} to abandon it; both are idempotent
124
+ * and every pending pull settles on the first one.
125
+ */
126
+ export declare class IncomingFeed {
127
+ #private;
128
+ constructor(shared: Shared);
129
+ /** Append one text fragment and wake every pending cursor. Throws once the feed is closed. */
130
+ push(fragment: string): void;
131
+ /** Mark the input complete. */
132
+ finish(): void;
133
+ /** Abandon the input; pending and future pulls reject with kind `aborted`. */
134
+ abort(): void;
135
+ }
136
+ /** Read side of one growing JSON document. */
137
+ export declare class IncomingDoc {
138
+ #private;
139
+ constructor(shared: Shared);
140
+ /** Create a push feed and its read-side document. */
141
+ static channel(): {
142
+ feed: IncomingFeed;
143
+ doc: IncomingDoc;
144
+ };
145
+ /** Text received so far. */
146
+ get text(): string;
147
+ /** Await explicit input completion; rejects with kind `aborted` if the feed was abandoned. */
148
+ finished(): Promise<void>;
149
+ /**
150
+ * Parse the entire finished document with the final tolerant parser.
151
+ * Waits for {@link IncomingFeed.finish}; aborted input is not decoded and a
152
+ * document that fails the final parse rejects with kind `malformed`.
153
+ */
154
+ whole<T = unknown>(): Promise<T>;
155
+ /** Cursor for the root JSON value. */
156
+ root(): IncomingJson;
157
+ }
158
+ /** Cursor for one JSON value in the incoming document, addressed by path. */
159
+ export declare class IncomingJson {
160
+ #private;
161
+ readonly path: readonly PullPathSegment[];
162
+ constructor(shared: Shared, path: readonly PullPathSegment[]);
163
+ /** Await the value's first token and report its JSON shape. */
164
+ kind(): Promise<IncomingValueKind>;
165
+ /** Await and parse the complete value. Containers go through the final tolerant parser. */
166
+ value<T = unknown>(): Promise<T>;
167
+ /** Await a complete number. */
168
+ number(): Promise<number>;
169
+ /** Await a complete boolean. */
170
+ boolean(): Promise<boolean>;
171
+ /** Await a complete `null`. */
172
+ null(): Promise<null>;
173
+ /** View this value as an incremental decoded string. */
174
+ string(): IncomingString;
175
+ /** View this value as an array of element cursors. */
176
+ array(): IncomingArray;
177
+ /** View this value as an object with keyed cursors. */
178
+ object(): IncomingObject;
179
+ }
180
+ /**
181
+ * Incremental decoded string consumer. Chunks are emitted in order without
182
+ * overlap and are always prefixes of the final decoded string;
183
+ * {@link text} returns the complete string independently of whether chunks
184
+ * were consumed. Async iteration yields chunks. Concurrent `nextChunk` /
185
+ * `nextLine` calls are served in call order, like a stream reader: a call
186
+ * whose result is abandoned still consumes its chunk.
187
+ */
188
+ export declare class IncomingString implements AsyncIterable<string> {
189
+ #private;
190
+ constructor(shared: Shared, path: readonly PullPathSegment[]);
191
+ /** Await the next stable decoded chunk, or `undefined` after the closing quote. */
192
+ nextChunk(): Promise<string | undefined>;
193
+ /**
194
+ * Await the next complete decoded line, retaining its trailing newline.
195
+ * A final unterminated line is returned once the closing quote arrives.
196
+ */
197
+ nextLine(): Promise<string | undefined>;
198
+ /** Iterate complete decoded lines. */
199
+ lines(): AsyncGenerator<string, void, undefined>;
200
+ [Symbol.asyncIterator](): AsyncGenerator<string, void, undefined>;
201
+ /** Await the closing quote and return the complete decoded string. */
202
+ text(): Promise<string>;
203
+ }
204
+ /**
205
+ * Linear cursor over elements of an incoming array. Async iteration yields
206
+ * element cursors. Concurrent `next` calls are served in call order.
207
+ */
208
+ export declare class IncomingArray implements AsyncIterable<IncomingJson> {
209
+ #private;
210
+ constructor(shared: Shared, path: readonly PullPathSegment[]);
211
+ /** Await the start of the next element; `undefined` only after the closing bracket. */
212
+ next(): Promise<IncomingJson | undefined>;
213
+ [Symbol.asyncIterator](): AsyncGenerator<IncomingJson, void, undefined>;
214
+ /** Await the closing bracket and collect the fully parsed elements. */
215
+ collect<T = unknown>(): Promise<T[]>;
216
+ }
217
+ /** Keyed cursor and final collection for an incoming object. */
218
+ export declare class IncomingObject {
219
+ #private;
220
+ constructor(shared: Shared, path: readonly PullPathSegment[]);
221
+ /**
222
+ * Cursor bound to the first occurrence of `name`. Pulling it makes the
223
+ * key required: a missing member rejects with kind `missing`.
224
+ */
225
+ key(name: string): IncomingJson;
226
+ /**
227
+ * Await the closing brace and collect the object through the final parser,
228
+ * whose duplicate keys are last-write-wins unlike {@link key}.
229
+ */
230
+ collect<T = Record<string, unknown>>(): Promise<T>;
231
+ }
232
+ export {};
@@ -10,6 +10,7 @@ export * from "./format.js";
10
10
  export * from "./frontmatter.js";
11
11
  export * from "./fs-error.js";
12
12
  export * from "./glob.js";
13
+ export * from "./incoming-json.js";
13
14
  export * from "./json.js";
14
15
  export * from "./json-parse.js";
15
16
  export * as logger from "./logger.js";
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Tolerant JSON lexer shared by the final parser (`parseJsonWithRepair`), the
3
+ * streaming partial builder (`parseStreamingJson`), and the incoming cursors
4
+ * (`IncomingDoc`). {@link JsonLexerMode} selects how truncation and unescaped
5
+ * inner double quotes are treated.
6
+ *
7
+ * The grammar is a forgiving superset of JSON covering malformations commonly
8
+ * produced by language models:
9
+ *
10
+ * - single-quoted strings and unquoted object keys (JSON5);
11
+ * - trailing / stray commas, and `//` + block comments;
12
+ * - Python literals `True` / `False` / `None`, plus `0x` / `0b` numbers;
13
+ * - raw control characters and invalid `\x` escapes inside strings (kept literally);
14
+ * - unescaped quotes inside strings — a single quote only closes a string when
15
+ * followed by a value terminator, recovering apostrophes such as `'it's'`;
16
+ * the same recovery applies to double quotes in `streaming` mode only,
17
+ * everywhere else they close strictly;
18
+ * - unquoted string values in value position — an unrecognized bareword such
19
+ * as `{"paths": packages/foo/*}` is recovered as a string up to the next
20
+ * `,` / `}` / `]` / newline.
21
+ */
22
+ export declare const QUOTE = 34;
23
+ export declare const SQUOTE = 39;
24
+ export declare const BACKSLASH = 92;
25
+ export declare const SLASH = 47;
26
+ export declare const COLON = 58;
27
+ export declare const COMMA = 44;
28
+ export declare const LBRACE = 123;
29
+ export declare const RBRACE = 125;
30
+ export declare const LBRACKET = 91;
31
+ export declare const RBRACKET = 93;
32
+ /** Valid chars after `\` in a strict JSON escape: `" \ / b f n r t u`. */
33
+ export declare const VALID_ESCAPE_CHAR: Uint8Array<ArrayBuffer>;
34
+ export declare function isHexDigit(cp: number): boolean;
35
+ /** JSON insignificant whitespace (RFC 8259 §2). */
36
+ export declare function isWhitespace(cp: number): boolean;
37
+ /** First char of a numeric token: sign, dot, or digit. */
38
+ export declare function isNumberStart(cp: number): boolean;
39
+ /**
40
+ * Grammar tolerance selected by the lexer's consumer.
41
+ *
42
+ * - `strict`: final parse — complete input required, double quotes close strictly.
43
+ * - `streaming`: mid-stream snapshot — incomplete tokens tolerated and unescaped
44
+ * inner double quotes recovered for display.
45
+ * - `incoming`: incremental typed pulls — incomplete tokens tolerated, but double
46
+ * quotes close strictly so pulled values match the final parse.
47
+ */
48
+ export type JsonLexerMode = "strict" | "streaming" | "incoming";
49
+ /** Decoded state of a string token at the current streaming edge. */
50
+ export interface JsonStringProgress {
51
+ /** Decoded content so far (complete when `complete` is true). */
52
+ value: string;
53
+ /**
54
+ * Length of the prefix of `value` whose meaning cannot change when more
55
+ * input arrives. Excludes a trailing split escape, a high surrogate whose
56
+ * low half may still follow, and everything from a quote whose close/inner
57
+ * reading is still undecidable at the buffer edge.
58
+ */
59
+ stableLen: number;
60
+ /** Whether the closing quote was consumed. */
61
+ complete: boolean;
62
+ }
63
+ /**
64
+ * Cursor over the input with the tolerant token readers. `pos` is the current
65
+ * offset; readers advance it. In `strict` mode a truncated or malformed token
66
+ * throws `SyntaxError`; the lenient modes report progress or return
67
+ * `undefined` so the caller can roll back.
68
+ */
69
+ export declare class JsonLexer {
70
+ #private;
71
+ readonly src: string;
72
+ readonly mode: JsonLexerMode;
73
+ pos: number;
74
+ constructor(src: string, mode: JsonLexerMode, pos?: number);
75
+ get atEnd(): boolean;
76
+ /** Char code at the cursor; `NaN` at end of input (so every comparison is false). */
77
+ peek(): number;
78
+ /** Skip whitespace plus `//` line and `/* *\/` block comments. */
79
+ ws(): void;
80
+ /**
81
+ * Read a string starting at the opening `quote`, retaining the information
82
+ * an incremental consumer needs. Strict mode throws on an unterminated
83
+ * string; lenient modes consume to the end of input and report progress.
84
+ */
85
+ string(quote: number): JsonStringProgress;
86
+ /**
87
+ * Read a numeric token with JS `Number()` semantics (decimal with optional
88
+ * sign / leading or trailing dot / exponent, plus `0x` hex and `0b` binary).
89
+ * Non-finite or malformed tokens throw in strict mode and return
90
+ * `undefined` in lenient modes; the cursor is left past the token either way.
91
+ */
92
+ number(): number | undefined;
93
+ /**
94
+ * Match a keyword literal at the cursor; consumes only on success and
95
+ * returns `undefined` otherwise. Requires a non-identifier boundary so
96
+ * `Truex` / `nullish` are not misread as the keyword followed by junk.
97
+ */
98
+ keyword(): boolean | null | undefined;
99
+ /** Read an unquoted object key: everything up to `:` / `,` / `}` / whitespace. May be empty. */
100
+ unquotedKey(): string;
101
+ /**
102
+ * Recover an unquoted string value, e.g. `{"paths": packages/foo/*}`:
103
+ * consume until `,` / `}` / `]` / newline and trim trailing whitespace.
104
+ * Recovery still fails — so a final parse never accepts a half-formed or
105
+ * non-finite argument — when the token:
106
+ * - hits end-of-input before a delimiter (truncated value);
107
+ * - contains a `"`, `{`, `[`, or a key-like `:` — this grammar accepts
108
+ * unquoted keys, so a missed comma (`{"a": foo "b": 1}`) would otherwise
109
+ * silently swallow the following field. A colon followed by `/` or `\`
110
+ * stays literal so URL and Windows-path values recover;
111
+ * - is a non-finite atom ({@link NON_RECOVERABLE_BAREWORDS}).
112
+ *
113
+ * Failure throws in strict mode and returns `undefined` in lenient modes.
114
+ */
115
+ bareword(): string | undefined;
116
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "18.1.5",
4
+ "version": "18.1.8",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -31,7 +31,7 @@
31
31
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "18.1.5"
34
+ "@oh-my-pi/pi-natives": "18.1.8"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/bun": "^1.3.14"
package/src/async.ts CHANGED
@@ -108,3 +108,19 @@ export class AsyncDrain<T> {
108
108
  return this.#promise;
109
109
  }
110
110
  }
111
+
112
+ /**
113
+ * Runs async operations one at a time in call order. Each `run` starts after
114
+ * the previous operation settles (success or failure) and returns that
115
+ * operation's own promise, so a rejected step never poisons the queue. Used by
116
+ * stateful cursors whose concurrent pulls must not interleave.
117
+ */
118
+ export class Serial {
119
+ #tail: Promise<unknown> = Promise.resolve();
120
+
121
+ run<T>(op: () => Promise<T>): Promise<T> {
122
+ const result = this.#tail.then(op, op);
123
+ this.#tail = result.catch(() => {});
124
+ return result;
125
+ }
126
+ }
package/src/dirs.ts CHANGED
@@ -949,6 +949,11 @@ export function getSecretPlaceholderKeyPath(): string {
949
949
  return keyPath;
950
950
  }
951
951
 
952
+ /** Directory holding the per-model tiny-worker sockets and logs (~/.omp/run/tiny; XDG default: $XDG_STATE_HOME/omp/run/tiny). */
953
+ export function getTinyWorkerRuntimeDir(): string {
954
+ return dirs.rootSubdir(path.join("run", "tiny"), "state");
955
+ }
956
+
952
957
  /** Root directory containing every per-project daemon runtime scope (~/.omp/run/daemons; XDG default: $XDG_STATE_HOME/omp/run/daemons). */
953
958
  export function getDaemonRuntimeRoot(): string {
954
959
  return dirs.rootSubdir(path.join("run", "daemons"), "state");