@hraness/dawg 0.2.0 → 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 (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. package/tui/play-strip.ts +143 -0
@@ -5,6 +5,7 @@ import {
5
5
  normalizeDelay,
6
6
  normalizeFilter,
7
7
  normalizeReverb,
8
+ normalizeSampler,
8
9
  scoreFromJSON,
9
10
  type ScoreOperation,
10
11
  } from "../../core/score.ts";
@@ -91,6 +92,41 @@ function parseOperation(value: unknown): ScoreOperation {
91
92
  ) {
92
93
  return { type: "clearTrack", trackId: value.trackId };
93
94
  }
95
+ if (
96
+ value.type === "removeTrack" &&
97
+ typeof value.trackId === "string" &&
98
+ value.trackId.length <= 64
99
+ ) {
100
+ return { type: "removeTrack", trackId: value.trackId };
101
+ }
102
+ if (
103
+ value.type === "moveTrack" &&
104
+ typeof value.trackId === "string" &&
105
+ value.trackId.length <= 64 &&
106
+ typeof value.index === "number" &&
107
+ Number.isInteger(value.index) &&
108
+ value.index >= 0 &&
109
+ value.index < SCORE_LIMITS.maxTracks
110
+ ) {
111
+ return { type: "moveTrack", trackId: value.trackId, index: value.index };
112
+ }
113
+ if (
114
+ value.type === "setKey" &&
115
+ (value.key === null ||
116
+ (typeof value.key === "string" &&
117
+ value.key.length <= SCORE_LIMITS.maxNameLength))
118
+ ) {
119
+ return { type: "setKey", key: value.key };
120
+ }
121
+ if (
122
+ value.type === "setMeter" &&
123
+ typeof value.beatsPerBar === "number" &&
124
+ Number.isInteger(value.beatsPerBar) &&
125
+ value.beatsPerBar >= 1 &&
126
+ value.beatsPerBar <= SCORE_LIMITS.maxBeatsPerBar
127
+ ) {
128
+ return { type: "setMeter", beatsPerBar: value.beatsPerBar };
129
+ }
94
130
  if (
95
131
  value.type === "updateTrack" &&
96
132
  typeof value.trackId === "string" &&
@@ -115,6 +151,8 @@ function parseOperation(value: unknown): ScoreOperation {
115
151
  safe.delay = normalizeDelay(patch.delay) ?? null;
116
152
  if (patch.reverb !== undefined)
117
153
  safe.reverb = normalizeReverb(patch.reverb) ?? null;
154
+ if (patch.sampler !== undefined)
155
+ safe.sampler = normalizeSampler(patch.sampler) ?? null;
118
156
  return { type: "updateTrack", trackId: value.trackId, patch: safe };
119
157
  }
120
158
  if (
@@ -6,9 +6,12 @@ import {
6
6
  PROVIDER_CHOICES,
7
7
  readConfig,
8
8
  resolveGatewayKey,
9
+ resolveOpenRouterKey,
9
10
  type AuthEnv,
10
11
  type CredentialSource,
12
+ type DawgConfig,
11
13
  type ProviderChoice,
14
+ type SubscriptionSlot,
12
15
  } from "../auth/credentials.ts";
13
16
  import { systemRunner } from "../auth/runner.ts";
14
17
  import {
@@ -20,45 +23,83 @@ import {
20
23
  } from "./agent.ts";
21
24
  import {
22
25
  createGatewayClient,
26
+ createOpenRouterClient,
27
+ type ApiProvider,
23
28
  type GatewayClient,
24
- type GatewayModel,
25
29
  } from "./gateway.ts";
30
+ import {
31
+ defaultModelId,
32
+ MODEL_CATALOG,
33
+ modelAlias,
34
+ resolveModelChoice,
35
+ } from "./models.ts";
26
36
  import { runTextAgentTurn } from "./xcb-agent.ts";
27
37
  import {
38
+ describeReason,
28
39
  readCapabilities,
29
40
  resolveXcbBin,
30
41
  shortModelLabel,
31
- xcbGenerate,
42
+ type XcbAccount,
43
+ xcbGenerateWithRetry,
32
44
  type XcbCapabilities,
33
45
  } from "./xcb.ts";
34
46
 
35
47
  /**
36
- * Which model backend a turn uses. `gateway` is the streaming tool-calling
37
- * loop on the Vercel AI Gateway; `xcb` emulates tools over `xcb --json
38
- * generate` with an AI subscription; `offline` means only direct commands.
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.
39
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
+ }>;
40
73
  export type ProviderSelection =
74
+ | ApiSelection
75
+ | XcbSelection
41
76
  | Readonly<{
42
- kind: "gateway";
77
+ kind: "offline";
43
78
  choice: ProviderChoice;
44
- apiKey: string;
45
- source: CredentialSource;
46
- }>
47
- | Readonly<{
48
- kind: "xcb";
49
- choice: ProviderChoice;
50
- bin: string;
51
- account: string;
52
- accountLabel: string;
53
- model: string;
54
- /** xcb admits this account on its first call, which takes longer. */
55
- admissionPending?: boolean;
56
- }>
57
- | Readonly<{ kind: "offline"; choice: ProviderChoice; reason: string }>;
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
+ }
58
92
 
59
93
  export const LOGIN_HINT = "run `dawg login` to enable the agent";
60
94
  /** The small, cheap model used for one-line helpers such as session names. */
61
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
+ }
62
103
 
63
104
  export function providerChoice(
64
105
  env: Readonly<Record<string, string | undefined>>,
@@ -70,10 +111,42 @@ export function providerChoice(
70
111
  return saved ?? "auto";
71
112
  }
72
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
+
73
144
  /**
74
145
  * Resolve the provider: `DAWG_PROVIDER`, then the choice saved by `dawg
75
- * login`, then `auto` (gateway when a key exists, else an available xcb
76
- * account, else offline).
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.
77
150
  */
78
151
  export async function selectProvider(
79
152
  auth: AuthEnv = defaultAuthEnv(systemRunner),
@@ -81,82 +154,129 @@ export async function selectProvider(
81
154
  ): Promise<ProviderSelection> {
82
155
  const config = await readConfig(auth);
83
156
  const choice = providerChoice(auth.env, config.provider);
84
- if (choice === "gateway" || choice === "auto") {
85
- const key = await resolveGatewayKey(auth);
86
- if (key)
87
- return { kind: "gateway", choice, apiKey: key.key, source: key.source };
88
- if (choice === "gateway")
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
+ }
89
179
  return {
90
- kind: "offline",
180
+ kind: service,
91
181
  choice,
92
- reason: `no AI Gateway key; ${LOGIN_HINT}`,
182
+ apiKey: key.key,
183
+ source: key.source,
184
+ modelId,
93
185
  };
186
+ }
187
+ if (choice === service)
188
+ return offline(
189
+ `no ${service === "gateway" ? "AI Gateway" : "OpenRouter"} key any more; run \`dawg login ${service}\``,
190
+ );
94
191
  }
95
192
  const bin = resolveXcbBin(auth.env, auth.runner);
96
193
  if (!bin)
97
- return {
98
- kind: "offline",
99
- choice,
100
- reason:
101
- choice === "xcb"
102
- ? "xcb is not installed; see `dawg login --xcb`"
103
- : `no model configured; ${LOGIN_HINT}`,
104
- };
194
+ return offline(
195
+ explicit
196
+ ? `xcb is not installed; run \`dawg login ${choice === "xcb" ? "--xcb" : choice}\``
197
+ : `no model configured; ${LOGIN_HINT}`,
198
+ );
105
199
  let capabilities: XcbCapabilities;
106
200
  try {
107
201
  capabilities =
108
202
  options.capabilities ?? (await readCapabilities(bin, auth.runner));
109
203
  } catch {
110
- return {
111
- kind: "offline",
112
- choice,
113
- reason: `xcb capabilities unavailable; ${LOGIN_HINT}`,
114
- };
204
+ return offline(`xcb capabilities unavailable; ${LOGIN_HINT}`);
115
205
  }
116
- const saved = config.xcb;
117
- if (saved) {
118
- const account = capabilities.accounts.find(
119
- (row) => row.id === saved.account,
120
- );
121
- if (
122
- account?.available &&
123
- account.models.some((model) => model.key === saved.model)
124
- )
125
- return {
126
- kind: "xcb",
127
- choice,
128
- bin,
129
- account: account.id,
130
- accountLabel: account.label,
131
- model: saved.model,
132
- ...(account.admission === "pending" ? { admissionPending: true } : {}),
133
- };
134
- if (choice === "xcb")
135
- return {
136
- kind: "offline",
137
- choice,
138
- reason: `xcb account ${saved.account.slice(0, 12)} is ${account?.reason ?? "missing"}; run \`dawg login --xcb\``,
139
- };
140
- }
141
- const account = capabilities.accounts.find((row) => row.available);
142
- if (account)
143
- return {
144
- kind: "xcb",
145
- choice,
146
- bin,
147
- account: account.id,
148
- accountLabel: account.label,
149
- model: account.models[0]!.key,
150
- ...(account.admission === "pending" ? { admissionPending: true } : {}),
151
- };
152
- return {
153
- kind: "offline",
206
+ const toSelection = (
207
+ family: string,
208
+ account: XcbAccount,
209
+ model: string,
210
+ ): XcbSelection => ({
211
+ kind: "xcb",
154
212
  choice,
155
- reason:
156
- choice === "xcb"
157
- ? "no xcb account is qualified for applications; run `dawg login --xcb`"
158
- : `no model configured; ${LOGIN_HINT}`,
159
- };
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
+ );
160
280
  }
161
281
 
162
282
  /**
@@ -179,26 +299,35 @@ export function providerFingerprint(
179
299
  // Presence only: a process's environment cannot change under it, and the
180
300
  // fingerprint must never carry secret material.
181
301
  parts.push(
182
- `env:${env.DAWG_PROVIDER ?? "-"}:${env.AI_GATEWAY_API_KEY ? 1 : 0}`,
302
+ `env:${env.DAWG_PROVIDER ?? "-"}:${env.AI_GATEWAY_API_KEY ? 1 : 0}:${env.OPENROUTER_API_KEY ? 1 : 0}`,
183
303
  );
184
304
  return parts.join("|");
185
305
  }
186
306
 
187
- /** `opus-5.5 · gateway`, `claude/sonnet · xcb`, or `offline`. */
188
- export function providerLabel(
189
- selection: ProviderSelection,
190
- gatewayModel: GatewayModel,
191
- ): string {
192
- if (selection.kind === "gateway") return `${gatewayModel} · gateway`;
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}`;
193
311
  if (selection.kind === "xcb")
194
- return `${shortModelLabel(selection.model)} · xcb`;
312
+ return `${shortModelLabel(selection.model)} · ${selection.family}`;
195
313
  return "offline";
196
314
  }
197
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
+
198
326
  export type ProviderTurnOptions = Readonly<{
199
327
  selection: ProviderSelection;
200
328
  prompt: string;
201
- model: GatewayModel;
329
+ /** Overrides the selection's model (tests); normally unset. */
330
+ model?: string;
202
331
  host: AgentHost;
203
332
  onEvent?: (event: AgentEvent) => void;
204
333
  signal?: AbortSignal;
@@ -220,20 +349,18 @@ export async function runProviderTurn(
220
349
  ...(options.signal ? { signal: options.signal } : {}),
221
350
  ...(options.budget ? { budget: options.budget } : {}),
222
351
  };
223
- if (selection.kind === "gateway")
352
+ if (isApiSelection(selection))
224
353
  return runAgentTurn({
225
354
  ...common,
226
- model: options.model,
227
- client:
228
- options.gatewayClient ??
229
- createGatewayClient({ apiKey: selection.apiKey }),
355
+ model: options.model ?? selection.modelId,
356
+ client: options.gatewayClient ?? apiClient(selection),
230
357
  });
231
358
  if (selection.kind === "xcb") {
232
359
  const runner = options.runner ?? systemRunner;
233
360
  return runTextAgentTurn({
234
361
  ...common,
235
362
  generate: (prompt, call) =>
236
- xcbGenerate({
363
+ xcbGenerateWithRetry({
237
364
  bin: selection.bin,
238
365
  runner,
239
366
  account: selection.account,
@@ -289,15 +416,16 @@ export async function generateText(
289
416
  ? AbortSignal.any([options.signal, timeout])
290
417
  : timeout;
291
418
  const input = prompt.slice(0, 16_000);
292
- if (selection.kind === "gateway") {
293
- const client =
294
- options.gatewayClient ??
295
- createGatewayClient({ apiKey: selection.apiKey });
419
+ if (isApiSelection(selection)) {
420
+ const client = options.gatewayClient ?? apiClient(selection);
296
421
  let text = "";
297
422
  for await (const event of client.stream(
298
423
  {
299
424
  model: "opus-5.5",
300
- modelId: GATEWAY_SMALL_MODEL,
425
+ modelId:
426
+ selection.kind === "openrouter"
427
+ ? OPENROUTER_SMALL_MODEL
428
+ : GATEWAY_SMALL_MODEL,
301
429
  maxTokens,
302
430
  messages: [{ role: "user", content: input }],
303
431
  maxResponseBytes: 16 * 1024,
@@ -310,7 +438,7 @@ export async function generateText(
310
438
  return text.trim().slice(0, 512);
311
439
  }
312
440
  if (selection.kind === "xcb") {
313
- const text = await xcbGenerate({
441
+ const text = await xcbGenerateWithRetry({
314
442
  bin: selection.bin,
315
443
  runner: options.runner ?? systemRunner,
316
444
  account: selection.account,
package/src/agent/sse.ts CHANGED
@@ -44,12 +44,16 @@ export async function* readSseData(
44
44
  total += chunk.value.byteLength;
45
45
  if (total > options.maxBytes) throw new SseBudgetError(options.maxBytes);
46
46
  buffer += decoder.decode(chunk.value, { stream: true });
47
- let newline = buffer.search(/\r\n|\r|\n/);
48
- while (newline >= 0) {
49
- const line = buffer.slice(0, newline);
50
- const width =
51
- buffer[newline] === "\r" && buffer[newline + 1] === "\n" ? 2 : 1;
52
- buffer = buffer.slice(newline + width);
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;
53
57
  if (line === "") {
54
58
  if (data.length > 0) {
55
59
  const payload = data.join("\n");
@@ -60,10 +64,11 @@ export async function* readSseData(
60
64
  } else if (line.startsWith("data:")) {
61
65
  data.push(line.slice(line[5] === " " ? 6 : 5));
62
66
  }
63
- newline = buffer.search(/\r\n|\r|\n/);
64
67
  }
68
+ if (start > 0) buffer = buffer.slice(start);
65
69
  }
66
70
  buffer += decoder.decode();
71
+ if (buffer.endsWith("\r")) buffer = buffer.slice(0, -1);
67
72
  if (buffer.startsWith("data:"))
68
73
  data.push(buffer.slice(buffer[5] === " " ? 6 : 5));
69
74
  if (data.length > 0) {
@@ -81,6 +86,25 @@ export async function* readSseData(
81
86
  }
82
87
  }
83
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
+
84
108
  function abortReason(signal: AbortSignal): Error {
85
109
  const reason: unknown = signal.reason;
86
110
  if (reason instanceof Error) return reason;