@hraness/dawg 0.0.0-stage → 0.3.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.
Files changed (113) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +327 -0
  3. package/LICENSE +21 -0
  4. package/README.md +213 -2
  5. package/core/diff.ts +249 -0
  6. package/core/drums.ts +102 -0
  7. package/core/key.ts +43 -0
  8. package/core/loop.ts +78 -0
  9. package/core/pitch.ts +60 -0
  10. package/core/score.ts +1388 -0
  11. package/core/sdk/eval-child.ts +113 -0
  12. package/core/sdk/eval.ts +257 -0
  13. package/core/sdk/print.ts +393 -0
  14. package/core/sdk/v1.ts +954 -0
  15. package/core/slug.ts +19 -0
  16. package/package.json +45 -4
  17. package/src/agent/agent.ts +853 -0
  18. package/src/agent/brief.ts +160 -0
  19. package/src/agent/gateway.ts +441 -0
  20. package/src/agent/models.ts +633 -0
  21. package/src/agent/ops.ts +157 -0
  22. package/src/agent/planner.ts +259 -0
  23. package/src/agent/provider.ts +454 -0
  24. package/src/agent/sse.ts +114 -0
  25. package/src/agent/tools.ts +1373 -0
  26. package/src/agent/usage.ts +296 -0
  27. package/src/agent/workspace.ts +683 -0
  28. package/src/agent/xcb-agent.ts +262 -0
  29. package/src/agent/xcb.ts +579 -0
  30. package/src/audio/click.ts +125 -0
  31. package/src/audio/clock.ts +68 -0
  32. package/src/audio/engine.ts +841 -0
  33. package/src/audio/live.ts +152 -0
  34. package/src/audio/lock.ts +57 -0
  35. package/src/audio/player.ts +134 -0
  36. package/src/audio/render-worker.ts +68 -0
  37. package/src/audio/renderer.ts +174 -0
  38. package/src/audio/sampler.ts +292 -0
  39. package/src/audio/samples.ts +683 -0
  40. package/src/audio/wav.ts +861 -0
  41. package/src/auth/cli.ts +231 -0
  42. package/src/auth/credentials.ts +411 -0
  43. package/src/auth/discover.ts +481 -0
  44. package/src/auth/login.ts +1191 -0
  45. package/src/auth/openrouter.ts +206 -0
  46. package/src/auth/picker.ts +282 -0
  47. package/src/auth/runner.ts +207 -0
  48. package/src/auth/tui.ts +107 -0
  49. package/src/commands/edit.ts +170 -0
  50. package/src/commands/help.ts +247 -0
  51. package/src/commands/history.ts +69 -0
  52. package/src/commands/music.ts +461 -0
  53. package/src/commands/sample.ts +302 -0
  54. package/src/daemon.ts +31 -0
  55. package/src/main.ts +2209 -0
  56. package/src/media/analyze.ts +364 -0
  57. package/src/media/backend.ts +253 -0
  58. package/src/media/cli.ts +173 -0
  59. package/src/media/download.ts +281 -0
  60. package/src/media/dsp.ts +281 -0
  61. package/src/media/import.ts +130 -0
  62. package/src/media/lyrics.ts +201 -0
  63. package/src/media/notes.ts +363 -0
  64. package/src/media/paths.ts +168 -0
  65. package/src/media/process.ts +226 -0
  66. package/src/media/registry.ts +9 -0
  67. package/src/media/sidecar.ts +72 -0
  68. package/src/media/stemdeck.ts +254 -0
  69. package/src/media/stems.ts +173 -0
  70. package/src/media/tools.ts +292 -0
  71. package/src/media/types.ts +92 -0
  72. package/src/media/vendor/basic-pitch.ts +261 -0
  73. package/src/media/vendor/drums.ts +817 -0
  74. package/src/media/vendor/grid.ts +203 -0
  75. package/src/media/vendor/util.ts +139 -0
  76. package/src/media/vendor/wav.ts +233 -0
  77. package/src/project/check.ts +80 -0
  78. package/src/project/init.ts +253 -0
  79. package/src/project/sync.ts +432 -0
  80. package/src/project/typecheck.ts +149 -0
  81. package/src/render.ts +121 -0
  82. package/src/session/attach.ts +181 -0
  83. package/src/session/client.ts +498 -0
  84. package/src/session/daemon.ts +740 -0
  85. package/src/session/delta.ts +249 -0
  86. package/src/session/list.ts +180 -0
  87. package/src/session/lock.ts +92 -0
  88. package/src/session/meta.ts +253 -0
  89. package/src/session/naming.ts +430 -0
  90. package/src/session/port.ts +481 -0
  91. package/src/session/presence.ts +159 -0
  92. package/src/session/protocol.ts +618 -0
  93. package/src/session/rebase.ts +168 -0
  94. package/src/session/store.ts +581 -0
  95. package/src/tui/menu.ts +1083 -0
  96. package/src/tui/play-mode.ts +442 -0
  97. package/src/tui/play-session.ts +636 -0
  98. package/src/web/fetch.ts +340 -0
  99. package/src/web/http.ts +137 -0
  100. package/src/web/search.ts +681 -0
  101. package/tui/activity.ts +364 -0
  102. package/tui/app.ts +1372 -0
  103. package/tui/drums.ts +65 -0
  104. package/tui/highway.ts +921 -0
  105. package/tui/input.ts +63 -0
  106. package/tui/keys.ts +102 -0
  107. package/tui/layers.ts +80 -0
  108. package/tui/play-strip.ts +143 -0
  109. package/tui/prompt.ts +609 -0
  110. package/tui/render.ts +124 -0
  111. package/tui/screen.ts +247 -0
  112. package/tui/text.ts +72 -0
  113. package/tui/theme.ts +451 -0
@@ -0,0 +1,454 @@
1
+ import { statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ configDir,
5
+ defaultAuthEnv,
6
+ PROVIDER_CHOICES,
7
+ readConfig,
8
+ resolveGatewayKey,
9
+ resolveOpenRouterKey,
10
+ type AuthEnv,
11
+ type CredentialSource,
12
+ type DawgConfig,
13
+ type ProviderChoice,
14
+ type SubscriptionSlot,
15
+ } from "../auth/credentials.ts";
16
+ import { systemRunner } from "../auth/runner.ts";
17
+ import {
18
+ runAgentTurn,
19
+ type AgentBudget,
20
+ type AgentEvent,
21
+ type AgentHost,
22
+ type AgentTurnResult,
23
+ } from "./agent.ts";
24
+ import {
25
+ createGatewayClient,
26
+ createOpenRouterClient,
27
+ type ApiProvider,
28
+ type GatewayClient,
29
+ } from "./gateway.ts";
30
+ import {
31
+ defaultModelId,
32
+ MODEL_CATALOG,
33
+ modelAlias,
34
+ resolveModelChoice,
35
+ } from "./models.ts";
36
+ import { runTextAgentTurn } from "./xcb-agent.ts";
37
+ import {
38
+ describeReason,
39
+ readCapabilities,
40
+ resolveXcbBin,
41
+ shortModelLabel,
42
+ type XcbAccount,
43
+ xcbGenerateWithRetry,
44
+ type XcbCapabilities,
45
+ } from "./xcb.ts";
46
+
47
+ /**
48
+ * Which model backend a turn uses. `gateway` and `openrouter` run the
49
+ * streaming tool-calling loop on an OpenAI-compatible API; `xcb` emulates
50
+ * tools over `xcb --json generate` with a Codex or Claude subscription;
51
+ * `offline` means only direct commands.
52
+ */
53
+ export type ApiSelection = Readonly<{
54
+ kind: ApiProvider;
55
+ choice: ProviderChoice;
56
+ apiKey: string;
57
+ source: CredentialSource;
58
+ /** The exact provider model ID (`anthropic/claude-opus-5.5`). */
59
+ modelId: string;
60
+ }>;
61
+ export type XcbSelection = Readonly<{
62
+ kind: "xcb";
63
+ choice: ProviderChoice;
64
+ /** `codex` or `claude` (or another xcb provider for `--xcb`). */
65
+ family: string;
66
+ bin: string;
67
+ account: string;
68
+ accountLabel: string;
69
+ model: string;
70
+ /** xcb admits this account on its first call, which takes longer. */
71
+ admissionPending?: boolean;
72
+ }>;
73
+ export type ProviderSelection =
74
+ | ApiSelection
75
+ | XcbSelection
76
+ | Readonly<{
77
+ kind: "offline";
78
+ choice: ProviderChoice;
79
+ reason: string;
80
+ /**
81
+ * Set when a saved choice stopped working (key revoked, account gone):
82
+ * the host says so once and offers the picker, never another provider.
83
+ */
84
+ invalidSaved?: boolean;
85
+ }>;
86
+
87
+ export function isApiSelection(
88
+ selection: ProviderSelection,
89
+ ): selection is ApiSelection {
90
+ return selection.kind === "gateway" || selection.kind === "openrouter";
91
+ }
92
+
93
+ export const LOGIN_HINT = "run `dawg login` to enable the agent";
94
+ /** The small, cheap model used for one-line helpers such as session names. */
95
+ export const GATEWAY_SMALL_MODEL = "anthropic/claude-haiku-4.5";
96
+ export const OPENROUTER_SMALL_MODEL = "anthropic/claude-haiku-4.5";
97
+
98
+ export function apiClient(selection: ApiSelection): GatewayClient {
99
+ return selection.kind === "openrouter"
100
+ ? createOpenRouterClient({ apiKey: selection.apiKey })
101
+ : createGatewayClient({ apiKey: selection.apiKey });
102
+ }
103
+
104
+ export function providerChoice(
105
+ env: Readonly<Record<string, string | undefined>>,
106
+ saved: ProviderChoice | undefined,
107
+ ): ProviderChoice {
108
+ const fromEnv = env.DAWG_PROVIDER?.trim().toLowerCase();
109
+ if (fromEnv && (PROVIDER_CHOICES as readonly string[]).includes(fromEnv))
110
+ return fromEnv as ProviderChoice;
111
+ return saved ?? "auto";
112
+ }
113
+
114
+ /**
115
+ * The model for a key-based provider: `DAWG_MODEL`, then the model saved by
116
+ * `/model` or `dawg login`, then Opus 5.5. Throws on an unknown `DAWG_MODEL`.
117
+ */
118
+ export function apiModelId(
119
+ service: ApiProvider,
120
+ env: Readonly<Record<string, string | undefined>>,
121
+ config: DawgConfig,
122
+ ): string {
123
+ const fromEnv = env.DAWG_MODEL?.trim();
124
+ if (fromEnv) {
125
+ const id = resolveModelChoice(service, fromEnv);
126
+ if (!id)
127
+ throw new Error(
128
+ `unknown DAWG_MODEL "${fromEnv.slice(0, 40)}"; use one of ${MODEL_CATALOG.map((row) => row.alias).join(", ")} or a vendor/model ID`,
129
+ );
130
+ return id;
131
+ }
132
+ return (
133
+ (service === "openrouter" ? config.openrouterModel : config.gatewayModel) ??
134
+ defaultModelId(service)
135
+ );
136
+ }
137
+
138
+ const FAMILY_NAMES: Readonly<Record<string, string>> = Object.freeze({
139
+ codex: "ChatGPT/Codex subscription",
140
+ claude: "Claude subscription",
141
+ xcb: "xcb account",
142
+ });
143
+
144
+ /**
145
+ * Resolve the provider: `DAWG_PROVIDER`, then the choice saved by `dawg
146
+ * login` or `/model`, then `auto` (AI Gateway key, then OpenRouter key, then
147
+ * a ready Codex or Claude subscription). A saved choice that stops working
148
+ * resolves to `offline` with `invalidSaved`; it never falls through to a
149
+ * different provider.
150
+ */
151
+ export async function selectProvider(
152
+ auth: AuthEnv = defaultAuthEnv(systemRunner),
153
+ options: { capabilities?: XcbCapabilities } = {},
154
+ ): Promise<ProviderSelection> {
155
+ const config = await readConfig(auth);
156
+ const choice = providerChoice(auth.env, config.provider);
157
+ const explicit = choice !== "auto";
158
+ const offline = (reason: string): ProviderSelection => ({
159
+ kind: "offline",
160
+ choice,
161
+ reason,
162
+ ...(explicit && config.provider === choice && !auth.env.DAWG_PROVIDER
163
+ ? { invalidSaved: true }
164
+ : {}),
165
+ });
166
+ for (const service of ["gateway", "openrouter"] as const) {
167
+ if (choice !== service && choice !== "auto") continue;
168
+ const key =
169
+ service === "gateway"
170
+ ? await resolveGatewayKey(auth)
171
+ : await resolveOpenRouterKey(auth);
172
+ if (key) {
173
+ let modelId: string;
174
+ try {
175
+ modelId = apiModelId(service, auth.env, config);
176
+ } catch (error) {
177
+ return offline((error as Error).message);
178
+ }
179
+ return {
180
+ kind: service,
181
+ choice,
182
+ apiKey: key.key,
183
+ source: key.source,
184
+ modelId,
185
+ };
186
+ }
187
+ if (choice === service)
188
+ return offline(
189
+ `no ${service === "gateway" ? "AI Gateway" : "OpenRouter"} key any more; run \`dawg login ${service}\``,
190
+ );
191
+ }
192
+ const bin = resolveXcbBin(auth.env, auth.runner);
193
+ if (!bin)
194
+ return offline(
195
+ explicit
196
+ ? `xcb is not installed; run \`dawg login ${choice === "xcb" ? "--xcb" : choice}\``
197
+ : `no model configured; ${LOGIN_HINT}`,
198
+ );
199
+ let capabilities: XcbCapabilities;
200
+ try {
201
+ capabilities =
202
+ options.capabilities ?? (await readCapabilities(bin, auth.runner));
203
+ } catch {
204
+ return offline(`xcb capabilities unavailable; ${LOGIN_HINT}`);
205
+ }
206
+ const toSelection = (
207
+ family: string,
208
+ account: XcbAccount,
209
+ model: string,
210
+ ): XcbSelection => ({
211
+ kind: "xcb",
212
+ choice,
213
+ family,
214
+ bin,
215
+ account: account.id,
216
+ accountLabel: account.label,
217
+ model,
218
+ ...(account.admission === "pending" ? { admissionPending: true } : {}),
219
+ });
220
+ const families: SubscriptionSlot[] =
221
+ choice === "auto"
222
+ ? ["codex", "claude"]
223
+ : explicit
224
+ ? [choice as SubscriptionSlot]
225
+ : [];
226
+ for (const family of families) {
227
+ const saved = config[family];
228
+ const inFamily = (account: XcbAccount) =>
229
+ family === "xcb" || account.provider === family;
230
+ if (saved) {
231
+ const account = capabilities.accounts.find(
232
+ (row) => row.id === saved.account,
233
+ );
234
+ if (
235
+ account?.available &&
236
+ account.models.some((model) => model.key === saved.model)
237
+ )
238
+ return toSelection(
239
+ family === "xcb" ? account.provider : family,
240
+ account,
241
+ saved.model,
242
+ );
243
+ if (choice === family)
244
+ return offline(
245
+ `your ${FAMILY_NAMES[family]} (${account?.label ?? saved.account.slice(0, 12)}) is ${
246
+ !account
247
+ ? "no longer listed by xcb"
248
+ : account.available
249
+ ? `missing model ${saved.model}`
250
+ : describeReason(account.reason).replace(
251
+ "<account>",
252
+ account.id,
253
+ )
254
+ }; run \`dawg login ${family === "xcb" ? "--xcb" : family}\``,
255
+ );
256
+ }
257
+ // No saved pick: the first usable account, ready before admission pending.
258
+ const usable = capabilities.accounts
259
+ .filter(
260
+ (account) =>
261
+ account.available && inFamily(account) && account.models.length > 0,
262
+ )
263
+ .sort(
264
+ (a, b) =>
265
+ Number(a.admission === "pending") - Number(b.admission === "pending"),
266
+ );
267
+ const account = usable[0];
268
+ if (account)
269
+ return toSelection(
270
+ family === "xcb" ? account.provider : family,
271
+ account,
272
+ account.models[0]!.key,
273
+ );
274
+ }
275
+ return offline(
276
+ explicit
277
+ ? `no ${FAMILY_NAMES[choice] ?? choice} is ready; run \`dawg login ${choice === "xcb" ? "--xcb" : choice}\``
278
+ : `no model configured; ${LOGIN_HINT}`,
279
+ );
280
+ }
281
+
282
+ /**
283
+ * A cheap fingerprint of everything on disk and in the environment that
284
+ * `selectProvider` reads (config.json, credentials.json, provider env vars).
285
+ * Two stats per call; the host re-resolves the provider when it changes.
286
+ */
287
+ export function providerFingerprint(
288
+ dir: string = configDir(),
289
+ env: Readonly<Record<string, string | undefined>> = process.env,
290
+ ): string {
291
+ const parts = ["config.json", "credentials.json"].map((name) => {
292
+ try {
293
+ const info = statSync(join(dir, name));
294
+ return `${name}:${info.size}:${info.mtimeMs}:${info.ino}`;
295
+ } catch {
296
+ return `${name}:-`;
297
+ }
298
+ });
299
+ // Presence only: a process's environment cannot change under it, and the
300
+ // fingerprint must never carry secret material.
301
+ parts.push(
302
+ `env:${env.DAWG_PROVIDER ?? "-"}:${env.AI_GATEWAY_API_KEY ? 1 : 0}:${env.OPENROUTER_API_KEY ? 1 : 0}`,
303
+ );
304
+ return parts.join("|");
305
+ }
306
+
307
+ /** `opus-5.5 · gateway`, `sonnet · claude`, or `offline`. */
308
+ export function providerLabel(selection: ProviderSelection): string {
309
+ if (isApiSelection(selection))
310
+ return `${modelAlias(selection.kind, selection.modelId)} · ${selection.kind}`;
311
+ if (selection.kind === "xcb")
312
+ return `${shortModelLabel(selection.model)} · ${selection.family}`;
313
+ return "offline";
314
+ }
315
+
316
+ /** The model half of the label, for the header and spend line. */
317
+ export function selectionModel(
318
+ selection: ProviderSelection,
319
+ ): string | undefined {
320
+ if (isApiSelection(selection))
321
+ return modelAlias(selection.kind, selection.modelId);
322
+ if (selection.kind === "xcb") return shortModelLabel(selection.model);
323
+ return undefined;
324
+ }
325
+
326
+ export type ProviderTurnOptions = Readonly<{
327
+ selection: ProviderSelection;
328
+ prompt: string;
329
+ /** Overrides the selection's model (tests); normally unset. */
330
+ model?: string;
331
+ host: AgentHost;
332
+ onEvent?: (event: AgentEvent) => void;
333
+ signal?: AbortSignal;
334
+ budget?: AgentBudget;
335
+ /** Injected for tests; defaults to a client built from the selection's key. */
336
+ gatewayClient?: GatewayClient;
337
+ runner?: AuthEnv["runner"];
338
+ }>;
339
+
340
+ /** Run one agent turn on whichever provider was selected. */
341
+ export async function runProviderTurn(
342
+ options: ProviderTurnOptions,
343
+ ): Promise<AgentTurnResult> {
344
+ const { selection } = options;
345
+ const common = {
346
+ prompt: options.prompt,
347
+ host: options.host,
348
+ ...(options.onEvent ? { onEvent: options.onEvent } : {}),
349
+ ...(options.signal ? { signal: options.signal } : {}),
350
+ ...(options.budget ? { budget: options.budget } : {}),
351
+ };
352
+ if (isApiSelection(selection))
353
+ return runAgentTurn({
354
+ ...common,
355
+ model: options.model ?? selection.modelId,
356
+ client: options.gatewayClient ?? apiClient(selection),
357
+ });
358
+ if (selection.kind === "xcb") {
359
+ const runner = options.runner ?? systemRunner;
360
+ return runTextAgentTurn({
361
+ ...common,
362
+ generate: (prompt, call) =>
363
+ xcbGenerateWithRetry({
364
+ bin: selection.bin,
365
+ runner,
366
+ account: selection.account,
367
+ model: selection.model,
368
+ prompt,
369
+ timeoutMs: call.timeoutMs,
370
+ maxOutputBytes: call.maxOutputBytes,
371
+ signal: call.signal,
372
+ }),
373
+ });
374
+ }
375
+ const result: AgentTurnResult = {
376
+ type: "error",
377
+ code: "provider",
378
+ message: selection.reason,
379
+ applied: 0,
380
+ revision: options.host.snapshot().revision,
381
+ };
382
+ options.onEvent?.(result);
383
+ return result;
384
+ }
385
+
386
+ export type GenerateTextOptions = Readonly<{
387
+ /** Upper bound on reply tokens (gateway `max_tokens`; xcb gets ~8 bytes per token). */
388
+ maxTokens: number;
389
+ signal?: AbortSignal;
390
+ timeoutMs?: number;
391
+ /** Reuse an already-resolved provider; otherwise one is selected now. */
392
+ selection?: ProviderSelection;
393
+ gatewayClient?: GatewayClient;
394
+ runner?: AuthEnv["runner"];
395
+ }>;
396
+
397
+ /**
398
+ * One short, tool-free completion on whichever provider is configured, for
399
+ * helpers such as the session auto-namer. Gateway uses `GATEWAY_SMALL_MODEL`;
400
+ * xcb uses the selected account and model with a small output cap. Throws when
401
+ * no provider is available (callers should fall back to a local default).
402
+ * The returned text is untrusted, trimmed, and at most 512 characters.
403
+ */
404
+ export async function generateText(
405
+ prompt: string,
406
+ options: GenerateTextOptions,
407
+ ): Promise<string> {
408
+ const selection = options.selection ?? (await selectProvider());
409
+ const maxTokens = Math.min(1024, Math.max(1, Math.floor(options.maxTokens)));
410
+ const timeoutMs = Math.min(
411
+ 60_000,
412
+ Math.max(1_000, options.timeoutMs ?? 20_000),
413
+ );
414
+ const timeout = AbortSignal.timeout(timeoutMs);
415
+ const signal = options.signal
416
+ ? AbortSignal.any([options.signal, timeout])
417
+ : timeout;
418
+ const input = prompt.slice(0, 16_000);
419
+ if (isApiSelection(selection)) {
420
+ const client = options.gatewayClient ?? apiClient(selection);
421
+ let text = "";
422
+ for await (const event of client.stream(
423
+ {
424
+ model: "opus-5.5",
425
+ modelId:
426
+ selection.kind === "openrouter"
427
+ ? OPENROUTER_SMALL_MODEL
428
+ : GATEWAY_SMALL_MODEL,
429
+ maxTokens,
430
+ messages: [{ role: "user", content: input }],
431
+ maxResponseBytes: 16 * 1024,
432
+ },
433
+ signal,
434
+ )) {
435
+ if (event.type === "text") text += event.delta;
436
+ if (text.length > 2048) break;
437
+ }
438
+ return text.trim().slice(0, 512);
439
+ }
440
+ if (selection.kind === "xcb") {
441
+ const text = await xcbGenerateWithRetry({
442
+ bin: selection.bin,
443
+ runner: options.runner ?? systemRunner,
444
+ account: selection.account,
445
+ model: selection.model,
446
+ prompt: input,
447
+ timeoutMs,
448
+ maxOutputBytes: Math.min(4096, Math.max(64, maxTokens * 8)),
449
+ signal,
450
+ });
451
+ return text.trim().slice(0, 512);
452
+ }
453
+ throw new Error(selection.reason);
454
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Minimal, bounded Server-Sent Events reader for streamed chat completions.
3
+ *
4
+ * It yields the `data:` payload of each event (multi-line data is joined with
5
+ * `\n`), skips comments and other fields, and stops at `[DONE]`. Every byte
6
+ * read counts against `maxBytes`, and an abort signal cancels the underlying
7
+ * reader even when the fetch implementation does not observe the signal.
8
+ */
9
+
10
+ export class SseBudgetError extends Error {
11
+ constructor(readonly maxBytes: number) {
12
+ super(`agent response exceeded ${maxBytes} bytes`);
13
+ this.name = "SseBudgetError";
14
+ }
15
+ }
16
+
17
+ export async function* readSseData(
18
+ body: ReadableStream<Uint8Array>,
19
+ options: { maxBytes: number; signal?: AbortSignal },
20
+ ): AsyncGenerator<string> {
21
+ const reader = body.getReader();
22
+ const decoder = new TextDecoder();
23
+ let buffer = "";
24
+ let data: string[] = [];
25
+ let total = 0;
26
+ const signal = options.signal;
27
+ let onAbort: (() => void) | undefined;
28
+ const aborted =
29
+ signal === undefined
30
+ ? undefined
31
+ : new Promise<never>((_, reject) => {
32
+ onAbort = () => reject(abortReason(signal));
33
+ if (signal.aborted) onAbort();
34
+ else signal.addEventListener("abort", onAbort, { once: true });
35
+ });
36
+ // Avoid an unhandled rejection when the stream finishes before an abort.
37
+ aborted?.catch(() => undefined);
38
+ try {
39
+ while (true) {
40
+ const chunk = aborted
41
+ ? await Promise.race([reader.read(), aborted])
42
+ : await reader.read();
43
+ if (chunk.done) break;
44
+ total += chunk.value.byteLength;
45
+ if (total > options.maxBytes) throw new SseBudgetError(options.maxBytes);
46
+ buffer += decoder.decode(chunk.value, { stream: true });
47
+ // Scan forward with indexOf and slice the consumed prefix once per
48
+ // chunk. A trailing "\r" is held back: it may be the first half of a
49
+ // "\r\n" split across chunks, and dispatching it early would double
50
+ // an empty line and end the event too soon.
51
+ let start = 0;
52
+ while (true) {
53
+ const next = lineBreak(buffer, start);
54
+ if (next === undefined) break;
55
+ const line = buffer.slice(start, next.index);
56
+ start = next.index + next.width;
57
+ if (line === "") {
58
+ if (data.length > 0) {
59
+ const payload = data.join("\n");
60
+ data = [];
61
+ if (payload === "[DONE]") return;
62
+ yield payload;
63
+ }
64
+ } else if (line.startsWith("data:")) {
65
+ data.push(line.slice(line[5] === " " ? 6 : 5));
66
+ }
67
+ }
68
+ if (start > 0) buffer = buffer.slice(start);
69
+ }
70
+ buffer += decoder.decode();
71
+ if (buffer.endsWith("\r")) buffer = buffer.slice(0, -1);
72
+ if (buffer.startsWith("data:"))
73
+ data.push(buffer.slice(buffer[5] === " " ? 6 : 5));
74
+ if (data.length > 0) {
75
+ const payload = data.join("\n");
76
+ if (payload !== "[DONE]") yield payload;
77
+ }
78
+ } finally {
79
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
80
+ await reader.cancel().catch(() => undefined);
81
+ try {
82
+ reader.releaseLock();
83
+ } catch {
84
+ // A pending read is rejected by cancel(); the lock is already moot.
85
+ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The next line terminator at or after `from`: "\n", "\r\n", or a lone
91
+ * "\r" that is already followed by another character. A "\r" at the very
92
+ * end of the buffer is not a terminator yet.
93
+ */
94
+ function lineBreak(
95
+ buffer: string,
96
+ from: number,
97
+ ): { index: number; width: number } | undefined {
98
+ const newline = buffer.indexOf("\n", from);
99
+ const carriage = buffer.indexOf("\r", from);
100
+ if (carriage < 0 || (newline >= 0 && newline < carriage))
101
+ return newline < 0 ? undefined : { index: newline, width: 1 };
102
+ if (carriage === buffer.length - 1) return undefined;
103
+ return buffer[carriage + 1] === "\n"
104
+ ? { index: carriage, width: 2 }
105
+ : { index: carriage, width: 1 };
106
+ }
107
+
108
+ function abortReason(signal: AbortSignal): Error {
109
+ const reason: unknown = signal.reason;
110
+ if (reason instanceof Error) return reason;
111
+ const error = new Error("agent request was aborted");
112
+ error.name = "AbortError";
113
+ return error;
114
+ }