@oh-my-pi/pi-utils 14.7.1 → 14.7.3
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/package.json +2 -2
- package/src/dirs.ts +25 -2
- package/src/stream.ts +132 -64
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-utils",
|
|
4
|
-
"version": "14.7.
|
|
4
|
+
"version": "14.7.3",
|
|
5
5
|
"description": "Shared utilities for pi packages",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/bun": "^1.3.13",
|
|
41
|
-
"@oh-my-pi/pi-natives": "14.7.
|
|
41
|
+
"@oh-my-pi/pi-natives": "14.7.3"
|
|
42
42
|
},
|
|
43
43
|
"engines": {
|
|
44
44
|
"bun": ">=1.3.7"
|
package/src/dirs.ts
CHANGED
|
@@ -192,6 +192,12 @@ class DirResolver {
|
|
|
192
192
|
|
|
193
193
|
let dirs = new DirResolver(process.env.PI_CODING_AGENT_DIR);
|
|
194
194
|
|
|
195
|
+
// Anchor home for the resolver. Captured at module load to stay stable across
|
|
196
|
+
// test mocks of `os.homedir()`. `getPluginsDir(home)` compares against this so
|
|
197
|
+
// production callers (`home === RESOLVER_HOME`) hit the XDG-aware resolver while
|
|
198
|
+
// tests passing a temp HOME short-circuit to a deterministic path.
|
|
199
|
+
const RESOLVER_HOME = os.homedir();
|
|
200
|
+
|
|
195
201
|
// =============================================================================
|
|
196
202
|
// Root directories
|
|
197
203
|
// =============================================================================
|
|
@@ -236,8 +242,20 @@ export function getLogPath(date = new Date()): string {
|
|
|
236
242
|
return path.join(getLogsDir(), `${APP_NAME}.${date.toISOString().slice(0, 10)}.log`);
|
|
237
243
|
}
|
|
238
244
|
|
|
239
|
-
/**
|
|
240
|
-
|
|
245
|
+
/**
|
|
246
|
+
* Get the plugins directory (~/.omp/plugins or its XDG equivalent).
|
|
247
|
+
*
|
|
248
|
+
* No-arg form (production callers) goes through the XDG-aware DirResolver so
|
|
249
|
+
* reads and writes always agree. The optional `home` parameter is for test
|
|
250
|
+
* isolation: when it differs from `os.homedir()` it short-circuits the resolver
|
|
251
|
+
* and returns `<home>/<configDir>/plugins` so tests with a temp HOME get a
|
|
252
|
+
* deterministic path. Passing `os.homedir()` explicitly is identical to the
|
|
253
|
+
* no-arg form — XDG semantics are preserved.
|
|
254
|
+
*/
|
|
255
|
+
export function getPluginsDir(home?: string): string {
|
|
256
|
+
if (home !== undefined && home !== RESOLVER_HOME) {
|
|
257
|
+
return path.join(home, getConfigDirName(), "plugins");
|
|
258
|
+
}
|
|
241
259
|
return dirs.rootSubdir("plugins", "data");
|
|
242
260
|
}
|
|
243
261
|
|
|
@@ -281,6 +299,11 @@ export function getPythonEnvDir(): string {
|
|
|
281
299
|
return dirs.rootSubdir("python-env", "data");
|
|
282
300
|
}
|
|
283
301
|
|
|
302
|
+
/** Get the shared Python gateway state directory (~/.omp/agent/python-gateway; XDG default: $XDG_STATE_HOME/omp/python-gateway). */
|
|
303
|
+
export function getPythonGatewayDir(): string {
|
|
304
|
+
return dirs.agentSubdir(undefined, "python-gateway", "state");
|
|
305
|
+
}
|
|
306
|
+
|
|
284
307
|
/** Get the puppeteer sandbox directory (~/.omp/puppeteer). */
|
|
285
308
|
export function getPuppeteerDir(): string {
|
|
286
309
|
return dirs.rootSubdir("puppeteer", "cache");
|
package/src/stream.ts
CHANGED
|
@@ -76,31 +76,6 @@ export async function* readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?:
|
|
|
76
76
|
// SSE (Server-Sent Events)
|
|
77
77
|
// =============================================================================
|
|
78
78
|
|
|
79
|
-
/** Byte lookup table: 1 = whitespace, 0 = not. */
|
|
80
|
-
const WS = new Uint8Array(256);
|
|
81
|
-
WS[0x09] = 1; // tab
|
|
82
|
-
WS[0x0a] = 1; // LF
|
|
83
|
-
WS[0x0d] = 1; // CR
|
|
84
|
-
WS[0x20] = 1; // space
|
|
85
|
-
|
|
86
|
-
const createPattern = (prefix: string) => {
|
|
87
|
-
const pre = Buffer.from(prefix, "utf-8");
|
|
88
|
-
return {
|
|
89
|
-
strip(buf: Uint8Array): number | null {
|
|
90
|
-
const n = pre.length;
|
|
91
|
-
if (buf.length < n) return null;
|
|
92
|
-
if (pre.equals(buf.subarray(0, n))) {
|
|
93
|
-
return n;
|
|
94
|
-
}
|
|
95
|
-
return null;
|
|
96
|
-
},
|
|
97
|
-
};
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const PAT_DATA = createPattern("data:");
|
|
101
|
-
|
|
102
|
-
const PAT_DONE = createPattern("[DONE]");
|
|
103
|
-
|
|
104
79
|
class ConcatSink {
|
|
105
80
|
#space?: Buffer;
|
|
106
81
|
#length = 0;
|
|
@@ -208,11 +183,15 @@ class ConcatSink {
|
|
|
208
183
|
}
|
|
209
184
|
}
|
|
210
185
|
|
|
211
|
-
const kDoneError = new Error("SSE stream done");
|
|
212
|
-
|
|
213
186
|
/**
|
|
214
187
|
* Stream parsed JSON objects from SSE `data:` lines.
|
|
215
188
|
*
|
|
189
|
+
* Thin wrapper over {@link readSseEvents}: yields one parsed JSON value per
|
|
190
|
+
* dispatched SSE event, skipping events with empty `data` and stopping at the
|
|
191
|
+
* OpenAI-style `[DONE]` sentinel. If your consumer doesn't care about `event:`
|
|
192
|
+
* names or doesn't need a custom parse step, use this; otherwise call
|
|
193
|
+
* `readSseEvents` directly.
|
|
194
|
+
*
|
|
216
195
|
* @example
|
|
217
196
|
* ```ts
|
|
218
197
|
* for await (const obj of readSseJson(response.body!)) {
|
|
@@ -221,61 +200,150 @@ const kDoneError = new Error("SSE stream done");
|
|
|
221
200
|
* ```
|
|
222
201
|
*/
|
|
223
202
|
export async function* readSseJson<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<T> {
|
|
224
|
-
const
|
|
225
|
-
|
|
203
|
+
for await (const sse of readSseEvents(stream, signal)) {
|
|
204
|
+
const data = sse.data;
|
|
205
|
+
if (data === "" || data === "[DONE]") {
|
|
206
|
+
if (data === "[DONE]") return;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
yield JSON.parse(data) as T;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
226
212
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
213
|
+
/**
|
|
214
|
+
* A single Server-Sent Event dispatched on a blank-line boundary.
|
|
215
|
+
*
|
|
216
|
+
* - `event` is the value of the most recent `event:` field, or `null` if none.
|
|
217
|
+
* - `data` is the concatenation (joined by `\n`) of every `data:` field in the
|
|
218
|
+
* event, exactly as required by the SSE spec.
|
|
219
|
+
* - `raw` is the list of decoded non-empty lines that made up the event,
|
|
220
|
+
* preserved for diagnostic context (error reporting, debugging). The
|
|
221
|
+
* dispatching blank line is not included.
|
|
222
|
+
*/
|
|
223
|
+
export interface ServerSentEvent {
|
|
224
|
+
event: string | null;
|
|
225
|
+
data: string;
|
|
226
|
+
raw: string[];
|
|
227
|
+
}
|
|
239
228
|
|
|
240
|
-
|
|
229
|
+
interface SseEventState {
|
|
230
|
+
event: string | null;
|
|
231
|
+
// `data` accumulates across multiple `data:` lines per the SSE spec, joined
|
|
232
|
+
// by `\n`. We keep the running string here and append as lines arrive instead
|
|
233
|
+
// of buffering an array and joining at flush. `null` means "no data: field
|
|
234
|
+
// seen yet" (distinct from a `data:` field with an empty value).
|
|
235
|
+
data: string | null;
|
|
236
|
+
raw: string[];
|
|
237
|
+
}
|
|
241
238
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
++beg;
|
|
247
|
-
}
|
|
248
|
-
if (beg >= end) return;
|
|
239
|
+
// Single decoder reused for all line decodes. Safe because lines are split on
|
|
240
|
+
// LF (0x0a) which is always a single-byte ASCII char in UTF-8 and never appears
|
|
241
|
+
// inside a multi-byte sequence — so each line is itself a complete UTF-8 run.
|
|
242
|
+
const SSE_LINE_DECODER = new TextDecoder("utf-8");
|
|
249
243
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
throw kDoneError;
|
|
254
|
-
}
|
|
244
|
+
function decodeSseLineBytes(line: Uint8Array, end: number): string {
|
|
245
|
+
return end === line.length ? SSE_LINE_DECODER.decode(line) : SSE_LINE_DECODER.decode(line.subarray(0, end));
|
|
246
|
+
}
|
|
255
247
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
248
|
+
function flushSseEvent(state: SseEventState): ServerSentEvent | null {
|
|
249
|
+
if (state.event === null && state.data === null) return null;
|
|
250
|
+
const event: ServerSentEvent = {
|
|
251
|
+
event: state.event,
|
|
252
|
+
data: state.data ?? "",
|
|
253
|
+
raw: state.raw,
|
|
254
|
+
};
|
|
255
|
+
state.event = null;
|
|
256
|
+
state.data = null;
|
|
257
|
+
state.raw = [];
|
|
258
|
+
return event;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function pushSseLine(line: Uint8Array, state: SseEventState): ServerSentEvent | null {
|
|
262
|
+
// `appendAndFlushLines` splits on LF only; strip a trailing CR so CRLF sources
|
|
263
|
+
// don't leak `\r` into field values.
|
|
264
|
+
let end = line.length;
|
|
265
|
+
if (end > 0 && line[end - 1] === 0x0d /* '\r' */) end--;
|
|
266
|
+
if (end === 0) return flushSseEvent(state);
|
|
267
|
+
|
|
268
|
+
// Comment line: keep in `raw` for diagnostic context, skip parsing.
|
|
269
|
+
if (line[0] === 0x3a /* ':' */) {
|
|
270
|
+
state.raw.push(decodeSseLineBytes(line, end));
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const text = decodeSseLineBytes(line, end);
|
|
275
|
+
state.raw.push(text);
|
|
276
|
+
|
|
277
|
+
const colon = text.indexOf(":");
|
|
278
|
+
const fieldName = colon === -1 ? text : text.slice(0, colon);
|
|
279
|
+
let value = colon === -1 ? "" : text.slice(colon + 1);
|
|
280
|
+
if (value.charCodeAt(0) === 0x20 /* ' ' */) value = value.slice(1);
|
|
281
|
+
|
|
282
|
+
if (fieldName === "event") {
|
|
283
|
+
state.event = value;
|
|
284
|
+
} else if (fieldName === "data") {
|
|
285
|
+
if (state.data === null) {
|
|
286
|
+
state.data = value;
|
|
287
|
+
} else {
|
|
288
|
+
state.data += "\n";
|
|
289
|
+
state.data += value;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// `id` and `retry` are intentionally ignored — the providers we consume
|
|
293
|
+
// don't use them, and the underlying transport handles reconnects itself.
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Stream raw Server-Sent Events from an HTTP response body.
|
|
299
|
+
*
|
|
300
|
+
* Yields one `ServerSentEvent` per blank-line dispatch. The consumer is
|
|
301
|
+
* responsible for parsing `data` (e.g. JSON, plain text, error envelope).
|
|
302
|
+
* Use `readSseJson` instead when every event is a single `data:` JSON object
|
|
303
|
+
* and you don't need access to the `event:` field.
|
|
304
|
+
*
|
|
305
|
+
* Internally backed by a Buffer-based line reader (`ConcatSink`) so chunk
|
|
306
|
+
* concatenation is O(n) and never triggers per-line string slicing of the
|
|
307
|
+
* accumulated buffer.
|
|
308
|
+
*
|
|
309
|
+
* @example
|
|
310
|
+
* ```ts
|
|
311
|
+
* for await (const sse of readSseEvents(response.body!)) {
|
|
312
|
+
* if (sse.event === "ping") continue;
|
|
313
|
+
* const obj = JSON.parse(sse.data);
|
|
314
|
+
* }
|
|
315
|
+
* ```
|
|
316
|
+
*/
|
|
317
|
+
export async function* readSseEvents(
|
|
318
|
+
stream: ReadableStream<Uint8Array>,
|
|
319
|
+
signal?: AbortSignal,
|
|
320
|
+
): AsyncGenerator<ServerSentEvent> {
|
|
321
|
+
const lineBuffer = new ConcatSink();
|
|
322
|
+
const state: SseEventState = { event: null, data: null, raw: [] };
|
|
323
|
+
const source = createAbortableStream(stream, signal);
|
|
324
|
+
try {
|
|
325
|
+
for await (const chunk of source) {
|
|
259
326
|
for (const line of lineBuffer.appendAndFlushLines(chunk)) {
|
|
260
|
-
|
|
327
|
+
const event = pushSseLine(line, state);
|
|
328
|
+
if (event) yield event;
|
|
261
329
|
}
|
|
262
330
|
}
|
|
331
|
+
// Treat any trailing partial line (no terminating LF) as a complete line.
|
|
263
332
|
if (!lineBuffer.isEmpty) {
|
|
264
333
|
const tail = lineBuffer.flush();
|
|
265
334
|
if (tail) {
|
|
266
335
|
lineBuffer.clear();
|
|
267
|
-
|
|
336
|
+
const event = pushSseLine(tail, state);
|
|
337
|
+
if (event) yield event;
|
|
268
338
|
}
|
|
269
339
|
}
|
|
340
|
+
// Real services don't always close on a blank line — flush any pending event.
|
|
341
|
+
const trailing = flushSseEvent(state);
|
|
342
|
+
if (trailing) yield trailing;
|
|
270
343
|
} catch (err) {
|
|
271
|
-
if (err === kDoneError) return;
|
|
272
|
-
// Abort errors are expected — just stop the generator.
|
|
273
344
|
if (signal?.aborted) return;
|
|
274
345
|
throw err;
|
|
275
346
|
}
|
|
276
|
-
if (!jsonBuffer.isEmpty) {
|
|
277
|
-
throw new Error("SSE stream ended unexpectedly");
|
|
278
|
-
}
|
|
279
347
|
}
|
|
280
348
|
|
|
281
349
|
/**
|