@hraness/dawg 0.6.1 → 0.8.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 (277) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DAWG.md +454 -140
  3. package/README.md +30 -26
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +184 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/ids.ts +52 -0
  11. package/core/instruments.ts +19 -0
  12. package/core/keys.ts +3 -3
  13. package/core/loop.ts +8 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/range.ts +581 -0
  17. package/core/resonators.ts +16 -2
  18. package/core/routing.ts +165 -0
  19. package/core/score.ts +955 -14
  20. package/core/sdk/eval-child.ts +7 -2
  21. package/core/sdk/eval.ts +35 -6
  22. package/core/sdk/print.ts +287 -5
  23. package/core/sdk/sync-lyrics.ts +49 -0
  24. package/core/sdk/v1.ts +2132 -46
  25. package/core/sections.ts +480 -35
  26. package/core/sing.ts +815 -0
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/guides/agent.md +29 -0
  50. package/guides/arrange.md +31 -0
  51. package/guides/audio.md +31 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -7
  54. package/guides/chords.md +15 -15
  55. package/guides/effects.md +17 -16
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +15 -7
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/panes.md +32 -0
  65. package/guides/performance.md +15 -13
  66. package/guides/play.md +21 -13
  67. package/guides/project.md +26 -8
  68. package/guides/providers.md +20 -14
  69. package/guides/resample.md +15 -9
  70. package/guides/rhythm.md +18 -13
  71. package/guides/sessions.md +17 -7
  72. package/guides/show-me.md +31 -0
  73. package/guides/sound.md +25 -9
  74. package/guides/sounds.md +15 -13
  75. package/guides/styles.md +31 -0
  76. package/guides/tape.md +32 -0
  77. package/guides/tempo.md +15 -10
  78. package/guides/tracks.md +15 -10
  79. package/guides/tuning.md +32 -0
  80. package/guides/voice.md +31 -0
  81. package/guides/web-search.md +18 -8
  82. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  83. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  84. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  85. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  86. package/native/prebuilt/manifest.json +21 -0
  87. package/package.json +6 -2
  88. package/src/agent/agent.ts +130 -18
  89. package/src/agent/calibration-tools.ts +53 -0
  90. package/src/agent/clip-tools.ts +453 -0
  91. package/src/agent/command-agent.ts +378 -0
  92. package/src/agent/drum-tools.ts +2 -2
  93. package/src/agent/expression-tools.ts +1 -1
  94. package/src/agent/gateway.ts +246 -60
  95. package/src/agent/models.ts +53 -12
  96. package/src/agent/ops.ts +27 -1
  97. package/src/agent/pack-tools.ts +1 -1
  98. package/src/agent/planner.ts +25 -0
  99. package/src/agent/portable-schema.ts +80 -0
  100. package/src/agent/preview-tool.ts +4 -1
  101. package/src/agent/provider.ts +22 -8
  102. package/src/agent/range-tools.ts +216 -0
  103. package/src/agent/rhythm-tools.ts +1 -1
  104. package/src/agent/section-tools.ts +1 -1
  105. package/src/agent/show-me.ts +497 -0
  106. package/src/agent/steer.ts +15 -0
  107. package/src/agent/style-tools.ts +217 -0
  108. package/src/agent/tool-error.ts +12 -0
  109. package/src/agent/tools.ts +110 -23
  110. package/src/agent/usage.ts +2 -2
  111. package/src/agent/voice-tools.ts +925 -0
  112. package/src/agent/xcb-agent.ts +13 -10
  113. package/src/argv.ts +38 -0
  114. package/src/audio/analysis.ts +253 -0
  115. package/src/audio/arrange.ts +49 -5
  116. package/src/audio/audio-command.ts +218 -0
  117. package/src/audio/autotune-engine.ts +101 -0
  118. package/src/audio/autotune.ts +640 -0
  119. package/src/audio/clips.ts +240 -0
  120. package/src/audio/devices.ts +264 -0
  121. package/src/audio/doctor.ts +157 -0
  122. package/src/audio/dsp/bandbank.ts +138 -0
  123. package/src/audio/dsp/envelope.ts +10 -0
  124. package/src/audio/dsp/follow.ts +120 -0
  125. package/src/audio/dsp/formant.ts +427 -0
  126. package/src/audio/dsp/glottal.ts +243 -0
  127. package/src/audio/dsp/interp.ts +7 -2
  128. package/src/audio/dsp/lpc.ts +50 -0
  129. package/src/audio/dsp/periodicity.ts +59 -0
  130. package/src/audio/dsp/pitch.ts +995 -0
  131. package/src/audio/dsp/psola.ts +199 -0
  132. package/src/audio/effects/chain.ts +3 -1
  133. package/src/audio/effects/common.ts +43 -0
  134. package/src/audio/effects/convolution.ts +7 -4
  135. package/src/audio/effects/filter.ts +48 -69
  136. package/src/audio/effects/formant.ts +263 -0
  137. package/src/audio/engine.ts +360 -40
  138. package/src/audio/fit.ts +35 -3
  139. package/src/audio/instrument-check.ts +59 -43
  140. package/src/audio/instruments.ts +4 -0
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +8 -1
  143. package/src/audio/keys/engine.ts +13 -1
  144. package/src/audio/keys/piano.ts +22 -2
  145. package/src/audio/kits.ts +135 -6
  146. package/src/audio/live.ts +114 -20
  147. package/src/audio/native.ts +615 -0
  148. package/src/audio/preview.ts +30 -2
  149. package/src/audio/render-worker.ts +2 -0
  150. package/src/audio/renderer.ts +2 -0
  151. package/src/audio/resample.ts +2 -1
  152. package/src/audio/sampler.ts +85 -4
  153. package/src/audio/samples.ts +20 -2
  154. package/src/audio/sing/analysis.ts +193 -0
  155. package/src/audio/sing/engine.ts +949 -0
  156. package/src/audio/strings/bow.ts +48 -5
  157. package/src/audio/strings/engine.ts +8 -2
  158. package/src/audio/synth/oscillators.ts +31 -21
  159. package/src/audio/synth/voice.ts +34 -1
  160. package/src/audio/vocoder/bank.ts +314 -0
  161. package/src/audio/vocoder/carrier.ts +165 -0
  162. package/src/audio/vocoder/control.ts +68 -0
  163. package/src/audio/vocoder/detect.ts +50 -0
  164. package/src/audio/vocoder/index.ts +304 -0
  165. package/src/audio/vocoder/talkbox.ts +143 -0
  166. package/src/audio/wav.ts +500 -77
  167. package/src/audio/winds/engine.ts +5 -1
  168. package/src/audio/winds/trim.ts +28 -4
  169. package/src/audio/winds/trims1.ts +297 -0
  170. package/src/audio/winds/voice.ts +15 -2
  171. package/src/auth/cli.ts +38 -36
  172. package/src/auth/credentials.ts +30 -1
  173. package/src/auth/login.ts +15 -9
  174. package/src/auth/tui.ts +19 -10
  175. package/src/commands/arrange.ts +82 -32
  176. package/src/commands/autotune.ts +421 -0
  177. package/src/commands/calibration.ts +74 -0
  178. package/src/commands/clips.ts +887 -0
  179. package/src/commands/drums.ts +3 -2
  180. package/src/commands/edit.ts +11 -4
  181. package/src/commands/expression.ts +1 -1
  182. package/src/commands/formant.ts +221 -0
  183. package/src/commands/fx.ts +101 -32
  184. package/src/commands/grammar.ts +560 -0
  185. package/src/commands/help.ts +774 -366
  186. package/src/commands/history.ts +139 -10
  187. package/src/commands/keys.ts +25 -8
  188. package/src/commands/modal.ts +1 -1
  189. package/src/commands/music.ts +1 -1
  190. package/src/commands/nearest.ts +53 -0
  191. package/src/commands/pack.ts +9 -2
  192. package/src/commands/param-range.ts +56 -0
  193. package/src/commands/parses.ts +135 -0
  194. package/src/commands/progression.ts +170 -0
  195. package/src/commands/range.ts +763 -0
  196. package/src/commands/resample.ts +13 -10
  197. package/src/commands/rhythm.ts +3 -0
  198. package/src/commands/rig.ts +3 -24
  199. package/src/commands/sample.ts +11 -1
  200. package/src/commands/sing.ts +474 -0
  201. package/src/commands/strum.ts +13 -1
  202. package/src/commands/style.ts +415 -0
  203. package/src/commands/time.ts +6 -3
  204. package/src/commands/tuning.ts +3 -3
  205. package/src/commands/vocal-pitch.ts +617 -0
  206. package/src/commands/vocal.ts +147 -0
  207. package/src/commands/vocoder.ts +627 -0
  208. package/src/commands/wind.ts +2 -2
  209. package/src/fs/durable.ts +50 -0
  210. package/src/identity/actor.ts +127 -0
  211. package/src/lang/glossary.ts +511 -0
  212. package/src/launch-args.ts +267 -0
  213. package/src/main.ts +2508 -308
  214. package/src/media/cli.ts +20 -3
  215. package/src/media/import.ts +3 -1
  216. package/src/project/check.ts +21 -2
  217. package/src/project/clip-pins.ts +72 -0
  218. package/src/project/init.ts +23 -8
  219. package/src/project/sync.ts +418 -86
  220. package/src/render.ts +20 -1
  221. package/src/session/client.ts +107 -3
  222. package/src/session/clipboard.ts +79 -0
  223. package/src/session/daemon.ts +199 -5
  224. package/src/session/live-host.ts +232 -0
  225. package/src/session/meta.ts +14 -0
  226. package/src/session/origin.ts +174 -0
  227. package/src/session/port.ts +109 -14
  228. package/src/session/presence.ts +34 -4
  229. package/src/session/protocol.ts +365 -7
  230. package/src/session/rebase.ts +63 -12
  231. package/src/session/receipt.ts +265 -0
  232. package/src/session/shared-live.ts +131 -0
  233. package/src/session/store.ts +126 -45
  234. package/src/tui/arrange-menu.ts +225 -23
  235. package/src/tui/audition.ts +1 -1
  236. package/src/tui/euclid.ts +113 -27
  237. package/src/tui/fader.ts +282 -42
  238. package/src/tui/granular-menu.ts +2 -4
  239. package/src/tui/knob-fields.ts +107 -0
  240. package/src/tui/knob-map.ts +198 -0
  241. package/src/tui/menu-clips.ts +297 -0
  242. package/src/tui/menu-time.ts +20 -13
  243. package/src/tui/menu-voice.ts +405 -0
  244. package/src/tui/menu.ts +987 -179
  245. package/src/tui/modal-menu.ts +6 -6
  246. package/src/tui/performance-menu.ts +5 -2
  247. package/src/tui/play-chords.ts +4 -2
  248. package/src/tui/play-mode.ts +15 -1
  249. package/src/tui/play-session.ts +243 -28
  250. package/src/tui/sing-menu.ts +278 -0
  251. package/src/tui/style-menu.ts +104 -0
  252. package/src/tui/tape-mode.ts +580 -0
  253. package/src/tui/tape-view.ts +263 -0
  254. package/src/tui/vocoder-menu.ts +244 -0
  255. package/src/tui/wind-menu.ts +3 -3
  256. package/src/version.ts +8 -0
  257. package/src/web/fetch.ts +115 -29
  258. package/tui/activity.ts +180 -9
  259. package/tui/app.ts +491 -84
  260. package/tui/clip-row.ts +132 -0
  261. package/tui/delight.ts +193 -0
  262. package/tui/drawer.ts +233 -27
  263. package/tui/frame-gate.ts +76 -0
  264. package/tui/grammar.ts +143 -65
  265. package/tui/guide.ts +50 -5
  266. package/tui/highway.ts +309 -30
  267. package/tui/hints.ts +197 -0
  268. package/tui/hits.ts +7 -1
  269. package/tui/input.ts +60 -9
  270. package/tui/keys.ts +1 -1
  271. package/tui/knobs.ts +268 -0
  272. package/tui/play-strip.ts +80 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +151 -18
  275. package/tui/tape.ts +439 -0
  276. package/tui/text.ts +35 -2
  277. package/tui/theme.ts +46 -1
@@ -1,4 +1,5 @@
1
- import { readSseData } from "./sse.ts";
1
+ import { isProviderModelId } from "../auth/credentials.ts";
2
+ import { readSseData, SseBudgetError } from "./sse.ts";
2
3
 
3
4
  /**
4
5
  * The two original model aliases. A model is either one of these or an exact
@@ -18,8 +19,6 @@ export const DEFAULT_MODEL_IDS: Readonly<Record<GatewayModel, string>> =
18
19
  "sol-6.1": "openai/gpt-6.1-sol",
19
20
  });
20
21
 
21
- const MODEL_ID_PATTERN =
22
- /^[a-z0-9][a-z0-9-]{0,63}\/[a-z0-9][a-z0-9._-]{0,127}$/i;
23
22
  const MAX_ERROR_BODY_BYTES = 4 * 1024;
24
23
 
25
24
  export function isGatewayModel(value: unknown): value is GatewayModel {
@@ -36,21 +35,22 @@ export function isGatewayModel(value: unknown): value is GatewayModel {
36
35
  export function resolveModelId(
37
36
  alias: string,
38
37
  overrides: Partial<Record<GatewayModel, string | undefined>> = {},
38
+ provider: ApiProvider = "gateway",
39
39
  ): string {
40
40
  if (!isGatewayModel(alias)) {
41
- if (MODEL_ID_PATTERN.test(alias)) return alias;
41
+ if (isProviderModelId(alias, provider)) return alias;
42
42
  throw new Error(
43
43
  `unknown model "${alias.slice(0, 32)}"; use ${GATEWAY_MODELS.join(" or ")} or a vendor/model ID`,
44
44
  );
45
45
  }
46
46
  const id = overrides[alias] ?? DEFAULT_MODEL_IDS[alias];
47
- if (!MODEL_ID_PATTERN.test(id))
47
+ if (!isProviderModelId(id, provider))
48
48
  throw new Error(`model ID for ${alias} must look like provider/model`);
49
49
  return id;
50
50
  }
51
51
 
52
- function checkedModelId(id: string): string {
53
- if (!MODEL_ID_PATTERN.test(id))
52
+ function checkedModelId(id: string, provider: ApiProvider): string {
53
+ if (!isProviderModelId(id, provider))
54
54
  throw new Error("model ID must look like provider/model");
55
55
  return id;
56
56
  }
@@ -85,6 +85,11 @@ export type ChatStreamRequest = {
85
85
  modelId?: string;
86
86
  maxTokens?: number;
87
87
  maxResponseBytes: number;
88
+ /**
89
+ * A model ID to try once when every attempt on the main model failed
90
+ * before producing a byte (a 5xx, a header timeout, a stalled stream).
91
+ */
92
+ fallbackModelId?: string;
88
93
  };
89
94
 
90
95
  /** Normalized stream events; provider chunk shapes never leave this module. */
@@ -145,6 +150,12 @@ export const GATEWAY_RETRIES = 2;
145
150
  const RETRY_BASE_MS = 500;
146
151
  /** Response headers must arrive within this long, per attempt. */
147
152
  export const GATEWAY_HEADER_TIMEOUT_MS = 15_000;
153
+ /**
154
+ * After headers, the first stream event must arrive within this long, per
155
+ * attempt. A provider that accepts the request and then sends nothing is
156
+ * retried like a header timeout: no byte has reached the caller yet.
157
+ */
158
+ export const GATEWAY_FIRST_BYTE_TIMEOUT_MS = 20_000;
148
159
 
149
160
  export type GatewayRetryOptions = Readonly<{
150
161
  retries?: number;
@@ -174,6 +185,7 @@ export type ApiClientOptions = {
174
185
  fetcher?: GatewayFetcher;
175
186
  retry?: GatewayRetryOptions;
176
187
  headerTimeoutMs?: number;
188
+ firstByteTimeoutMs?: number;
177
189
  };
178
190
 
179
191
  /** The streaming tool-calling client for OpenRouter's OpenAI-compatible API. */
@@ -212,6 +224,8 @@ function createApiClient(
212
224
  const sleep = options.retry?.sleep ?? ((ms: number) => Bun.sleep(ms));
213
225
  const random = options.retry?.random ?? Math.random;
214
226
  const headerTimeoutMs = options.headerTimeoutMs ?? GATEWAY_HEADER_TIMEOUT_MS;
227
+ const firstByteTimeoutMs =
228
+ options.firstByteTimeoutMs ?? GATEWAY_FIRST_BYTE_TIMEOUT_MS;
215
229
  const overrides: Partial<Record<GatewayModel, string | undefined>> = {
216
230
  "opus-5.5": process.env.DAWG_OPUS_MODEL || undefined,
217
231
  "sol-6.1": process.env.DAWG_SOL_MODEL || undefined,
@@ -222,50 +236,82 @@ function createApiClient(
222
236
 
223
237
  return {
224
238
  provider,
225
- modelId: (model) => resolveModelId(model, overrides),
239
+ modelId: (model) => resolveModelId(model, overrides, provider),
226
240
  async *stream(request, signal) {
227
- const model =
241
+ const primary =
228
242
  request.modelId !== undefined
229
- ? checkedModelId(request.modelId)
230
- : resolveModelId(request.model, overrides);
243
+ ? checkedModelId(request.modelId, provider)
244
+ : resolveModelId(request.model, overrides, provider);
245
+ const fallback =
246
+ request.fallbackModelId !== undefined &&
247
+ request.fallbackModelId !== primary
248
+ ? checkedModelId(request.fallbackModelId, provider)
249
+ : undefined;
231
250
  if (!apiKey)
232
251
  throw new GatewayError(
233
252
  provider === "openrouter"
234
- ? "no OpenRouter key; run `dawg login openrouter` or set OPENROUTER_API_KEY"
235
- : "no AI Gateway key; run `dawg login` or set AI_GATEWAY_API_KEY",
253
+ ? "no OpenRouter key; run `dawg model key openrouter` or set OPENROUTER_API_KEY"
254
+ : "no AI Gateway key; run `dawg model key` or set AI_GATEWAY_API_KEY",
236
255
  );
237
- const body: Record<string, unknown> = {
238
- model,
239
- messages: request.messages,
240
- stream: true,
241
- // The final chunk then carries token usage (and OpenRouter's cost).
242
- stream_options: { include_usage: true },
256
+ const encodeFor = (model: string): string => {
257
+ const body: Record<string, unknown> = {
258
+ model,
259
+ messages: withPromptCache(provider, model, request.messages),
260
+ stream: true,
261
+ // The final chunk then carries token usage (and OpenRouter's cost).
262
+ stream_options: { include_usage: true },
263
+ };
264
+ if (provider === "openrouter") body.usage = { include: true };
265
+ // The Gateway adds Anthropic cache breakpoints itself when asked; it
266
+ // documents the option as a no-op for implicitly caching providers.
267
+ else body.providerOptions = { gateway: { caching: "auto" } };
268
+ if (request.tools && request.tools.length > 0) {
269
+ body.tools = request.tools;
270
+ body.tool_choice = "auto";
271
+ }
272
+ if (request.temperature !== undefined)
273
+ body.temperature = request.temperature;
274
+ if (
275
+ request.maxTokens !== undefined &&
276
+ Number.isInteger(request.maxTokens) &&
277
+ request.maxTokens > 0
278
+ )
279
+ body.max_tokens = Math.min(request.maxTokens, 4096);
280
+ return JSON.stringify(body);
243
281
  };
244
- if (provider === "openrouter") body.usage = { include: true };
245
- if (request.tools && request.tools.length > 0) {
246
- body.tools = request.tools;
247
- body.tool_choice = "auto";
248
- }
249
- if (request.temperature !== undefined)
250
- body.temperature = request.temperature;
251
- if (
252
- request.maxTokens !== undefined &&
253
- Number.isInteger(request.maxTokens) &&
254
- request.maxTokens > 0
255
- )
256
- body.max_tokens = Math.min(request.maxTokens, 4096);
257
- const encoded = JSON.stringify(body);
282
+ let model = primary;
283
+ let encoded = encodeFor(model);
258
284
  // Retry only before any byte of a response has been consumed: a
259
285
  // network failure, a header-phase timeout, or 408/429/5xx. Once the
260
286
  // stream is flowing, a failure surfaces as-is (replaying could repeat
261
287
  // tool calls the model already made).
262
- let response: Response | undefined;
288
+ let events: AsyncIterator<string> | undefined;
289
+ let first: IteratorResult<string> | undefined;
290
+ const streamOptions: { maxBytes: number; signal?: AbortSignal } = {
291
+ maxBytes: request.maxResponseBytes,
292
+ };
263
293
  for (let attempt = 0; ; attempt += 1) {
264
294
  if (signal?.aborted) throw abortError(signal);
265
- const headerSignal = AbortSignal.any([
266
- ...(signal === undefined ? [] : [signal]),
267
- AbortSignal.timeout(headerTimeoutMs),
268
- ]);
295
+ // The header budget bounds only the wait for response headers. A
296
+ // fetch signal also governs the body, so the timer is cleared as soon
297
+ // as headers arrive; after that only the caller's signal (the user or
298
+ // the turn budget) can cut the stream short.
299
+ const headerController = new AbortController();
300
+ const headerTimer = setTimeout(
301
+ () =>
302
+ headerController.abort(
303
+ new DOMException(
304
+ `${name} did not respond within ${headerTimeoutMs} ms`,
305
+ "TimeoutError",
306
+ ),
307
+ ),
308
+ headerTimeoutMs,
309
+ );
310
+ const headerSignal = headerController.signal;
311
+ const requestSignal =
312
+ signal === undefined
313
+ ? headerSignal
314
+ : AbortSignal.any([signal, headerSignal]);
269
315
  const init: RequestInit = {
270
316
  method: "POST",
271
317
  headers: {
@@ -275,57 +321,197 @@ function createApiClient(
275
321
  ...(provider === "openrouter" ? OPENROUTER_HEADERS : {}),
276
322
  },
277
323
  body: encoded,
278
- signal: headerSignal,
324
+ signal: requestSignal,
279
325
  };
280
326
  let reason: string;
281
327
  try {
282
- const candidate = await fetcher(`${baseUrl}/chat/completions`, init);
328
+ let candidate: Response;
329
+ try {
330
+ candidate = await fetcher(`${baseUrl}/chat/completions`, init);
331
+ } finally {
332
+ clearTimeout(headerTimer);
333
+ }
283
334
  if (candidate.ok) {
284
- response = candidate;
285
- break;
335
+ if (!candidate.body)
336
+ throw new GatewayError(`${name} returned an empty stream`);
337
+ // Wait for the first event under its own budget; the stall
338
+ // signal is never fired once it has arrived.
339
+ const stall = new AbortController();
340
+ const stallTimer = setTimeout(
341
+ () =>
342
+ stall.abort(
343
+ new DOMException(
344
+ `${name} sent nothing within ${firstByteTimeoutMs} ms`,
345
+ "TimeoutError",
346
+ ),
347
+ ),
348
+ firstByteTimeoutMs,
349
+ );
350
+ streamOptions.signal =
351
+ signal === undefined
352
+ ? stall.signal
353
+ : AbortSignal.any([signal, stall.signal]);
354
+ // Any byte, a keep-alive comment included, proves the provider
355
+ // is alive and disarms the stall timer.
356
+ const watched = candidate.body.pipeThrough(
357
+ new TransformStream<Uint8Array, Uint8Array>({
358
+ transform(chunk, controller) {
359
+ clearTimeout(stallTimer);
360
+ controller.enqueue(chunk);
361
+ },
362
+ }),
363
+ );
364
+ const iterator = readSseData(watched, streamOptions)[
365
+ Symbol.asyncIterator
366
+ ]();
367
+ try {
368
+ first = await iterator.next();
369
+ events = iterator;
370
+ break;
371
+ } catch (error) {
372
+ if (!stall.signal.aborted || signal?.aborted) throw error;
373
+ await iterator.return?.(undefined).catch(() => undefined);
374
+ if (attempt >= retries) {
375
+ if (fallback !== undefined && model !== fallback) {
376
+ model = fallback;
377
+ encoded = encodeFor(model);
378
+ attempt = -1;
379
+ yield {
380
+ type: "activity",
381
+ message: `falling back to ${model} (stalled)…`,
382
+ };
383
+ continue;
384
+ }
385
+ throw new GatewayError(
386
+ `${name} sent nothing within ${firstByteTimeoutMs} ms`,
387
+ );
388
+ }
389
+ reason = "stalled";
390
+ } finally {
391
+ clearTimeout(stallTimer);
392
+ }
393
+ yield { type: "activity", message: `retrying (${reason})…` };
394
+ await sleep(
395
+ Math.round(retryBaseMs * 2 ** attempt * (0.5 + random())),
396
+ );
397
+ continue;
286
398
  }
287
399
  const detail = redact(await boundedErrorDetail(candidate));
288
400
  const error = new GatewayError(
289
401
  `${name} request failed (${candidate.status})${detail ? `: ${detail}` : ""}`,
290
402
  candidate.status,
291
403
  );
292
- if (!retryableStatus(candidate.status) || attempt >= retries)
293
- throw error;
404
+ if (!retryableStatus(candidate.status)) throw error;
405
+ if (attempt >= retries) {
406
+ if (fallback === undefined || model === fallback) throw error;
407
+ reason = String(candidate.status);
408
+ model = fallback;
409
+ encoded = encodeFor(model);
410
+ attempt = -1;
411
+ yield {
412
+ type: "activity",
413
+ message: `falling back to ${model} (${reason})…`,
414
+ };
415
+ continue;
416
+ }
294
417
  reason = String(candidate.status);
295
418
  } catch (error) {
296
- if (error instanceof GatewayError) throw error;
419
+ if (error instanceof GatewayError || error instanceof SseBudgetError)
420
+ throw error;
297
421
  if (signal?.aborted) throw abortError(signal);
298
- if (attempt >= retries)
299
- throw new GatewayError(
422
+ if (attempt >= retries) {
423
+ const failure = new GatewayError(
300
424
  headerSignal.aborted
301
425
  ? `${name} did not respond within ${headerTimeoutMs} ms`
302
426
  : `${name} request failed: ${redact(error instanceof Error ? error.message : String(error))}`,
303
427
  );
428
+ if (fallback === undefined || model === fallback) throw failure;
429
+ model = fallback;
430
+ encoded = encodeFor(model);
431
+ attempt = -1;
432
+ yield {
433
+ type: "activity",
434
+ message: `falling back to ${model} (${headerSignal.aborted ? "timeout" : "network"})…`,
435
+ };
436
+ continue;
437
+ }
304
438
  reason = headerSignal.aborted ? "timeout" : "network";
305
439
  }
306
440
  yield { type: "activity", message: `retrying (${reason})…` };
307
441
  const backoff = retryBaseMs * 2 ** attempt;
308
442
  await sleep(Math.round(backoff * (0.5 + random())));
309
443
  }
310
- if (!response.body)
311
- throw new GatewayError(`${name} returned an empty stream`);
312
- const streamOptions: { maxBytes: number; signal?: AbortSignal } = {
313
- maxBytes: request.maxResponseBytes,
314
- };
315
- if (signal !== undefined) streamOptions.signal = signal;
316
- for await (const data of readSseData(response.body, streamOptions)) {
317
- let chunk: unknown;
318
- try {
319
- chunk = JSON.parse(data);
320
- } catch {
321
- throw new GatewayError(`${name} sent a malformed stream chunk`);
444
+ try {
445
+ for (let next = first; !next.done; next = await events.next()) {
446
+ const data = next.value;
447
+ let chunk: unknown;
448
+ try {
449
+ chunk = JSON.parse(data);
450
+ } catch {
451
+ throw new GatewayError(`${name} sent a malformed stream chunk`);
452
+ }
453
+ yield* normalizeChunk(chunk, redact, name);
322
454
  }
323
- yield* normalizeChunk(chunk, redact, name);
455
+ } catch (error) {
456
+ // A failure while the body streams (a reset connection, a transport
457
+ // timeout) is a provider failure, not an internal one. Budget errors
458
+ // and the caller's own abort keep their identity.
459
+ if (
460
+ error instanceof GatewayError ||
461
+ error instanceof SseBudgetError ||
462
+ signal?.aborted
463
+ )
464
+ throw error;
465
+ const timedOut =
466
+ error instanceof Error && error.name === "TimeoutError";
467
+ throw new GatewayError(
468
+ timedOut
469
+ ? `${name} stream timed out`
470
+ : `${name} stream failed: ${redact(error instanceof Error ? error.message : String(error)).slice(0, 300)}`,
471
+ );
324
472
  }
325
473
  },
326
474
  };
327
475
  }
328
476
 
477
+ /**
478
+ * Mark the leading system message as a prompt-cache breakpoint for Anthropic
479
+ * models on OpenRouter and the AI Gateway. Anthropic caches tools, then
480
+ * system, then messages, so a breakpoint on the static system prompt caches
481
+ * the tool schemas and the prompt (about 27K tokens) across every step of a
482
+ * turn; the per-step brief follows it and stays uncached. The Gateway's
483
+ * `caching: "auto"` option alone cached nothing on its chat completions
484
+ * endpoint (bench/agent-eval measured 0 cached tokens over 4M), so the
485
+ * breakpoint is explicit there too. Other providers cache prefixes
486
+ * implicitly, so their messages go out unchanged.
487
+ */
488
+ export function withPromptCache(
489
+ provider: ApiProvider,
490
+ model: string,
491
+ messages: readonly ChatMessage[],
492
+ ): readonly unknown[] {
493
+ const first = messages[0];
494
+ if (
495
+ !model.toLowerCase().startsWith("anthropic/") ||
496
+ first?.role !== "system" ||
497
+ first.content.length === 0
498
+ )
499
+ return messages;
500
+ return [
501
+ {
502
+ role: "system",
503
+ content: [
504
+ {
505
+ type: "text",
506
+ text: first.content,
507
+ cache_control: { type: "ephemeral" },
508
+ },
509
+ ],
510
+ },
511
+ ...messages.slice(1),
512
+ ];
513
+ }
514
+
329
515
  function* normalizeChunk(
330
516
  chunk: unknown,
331
517
  redact: (text: string) => string,
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { writePrivateJson } from "../auth/credentials.ts";
3
+ import { isProviderModelId, writePrivateJson } from "../auth/credentials.ts";
4
4
  import {
5
5
  DEFAULT_MODEL_IDS,
6
6
  GATEWAY_BASE_URL,
@@ -27,6 +27,11 @@ export type CatalogEntry = Readonly<{
27
27
  class: ModelClass;
28
28
  gateway?: string;
29
29
  openrouter?: string;
30
+ /**
31
+ * Alias of a model on another vendor to try once when this one fails
32
+ * before its first byte (repeated 5xx, header timeout, stalled stream).
33
+ */
34
+ fallback?: string;
30
35
  }>;
31
36
 
32
37
  /**
@@ -71,6 +76,14 @@ export const MODEL_CATALOG: readonly CatalogEntry[] = Object.freeze([
71
76
  gateway: "anthropic/claude-sonnet-5.5",
72
77
  openrouter: "anthropic/claude-sonnet-5.5",
73
78
  },
79
+ {
80
+ alias: "haiku-5.5",
81
+ label: "Claude Haiku 5.5",
82
+ class: "fast",
83
+ gateway: "anthropic/claude-haiku-5.5",
84
+ openrouter: "anthropic/claude-haiku-5.5",
85
+ fallback: "glm-5.3-flash",
86
+ },
74
87
  {
75
88
  alias: "haiku-4.5",
76
89
  label: "Claude Haiku 4.5",
@@ -120,6 +133,13 @@ export const MODEL_CATALOG: readonly CatalogEntry[] = Object.freeze([
120
133
  gateway: "zai/glm-5.3",
121
134
  openrouter: "z-ai/glm-5.3",
122
135
  },
136
+ {
137
+ alias: "glm-5.3-flash",
138
+ label: "GLM-5.3 Flash",
139
+ class: "fast",
140
+ gateway: "zai/glm-5.3-flash",
141
+ openrouter: "z-ai/glm-5.3-flash",
142
+ },
123
143
  {
124
144
  alias: "llama-4-maverick",
125
145
  label: "Llama 4 Maverick",
@@ -129,28 +149,48 @@ export const MODEL_CATALOG: readonly CatalogEntry[] = Object.freeze([
129
149
  },
130
150
  ]);
131
151
 
132
- const MODEL_ID_PATTERN =
133
- /^[a-z0-9][a-z0-9-]{0,63}\/[a-z0-9][a-z0-9._-]{0,127}$/i;
134
-
135
152
  export function defaultModelId(provider: ApiProvider): string {
136
153
  return provider === "openrouter"
137
154
  ? MODEL_CATALOG[0]!.openrouter!
138
155
  : DEFAULT_MODEL_IDS["opus-5.5"];
139
156
  }
140
157
 
141
- /** An alias, a catalog label, or an exact `vendor/model` ID → the provider's ID. */
158
+ /**
159
+ * `/model fast` picks this alias: the fastest model measured by
160
+ * bench/agent-eval that passes the non-style tasks about as well as the
161
+ * default (see docs/model-eval.md).
162
+ */
163
+ export const FAST_MODEL_ALIAS = "haiku-5.5";
164
+
165
+ /** The provider ID to fall back to for `id`, if its catalog row names one. */
166
+ export function fallbackModelId(
167
+ provider: ApiProvider,
168
+ id: string,
169
+ ): string | undefined {
170
+ const row = MODEL_CATALOG.find((entry) => entry[provider] === id);
171
+ if (!row?.fallback) return undefined;
172
+ return MODEL_CATALOG.find((entry) => entry.alias === row.fallback)?.[
173
+ provider
174
+ ];
175
+ }
176
+
177
+ /**
178
+ * An alias (`fast` included), a catalog label, or an exact `vendor/model` ID
179
+ * → the provider's ID.
180
+ */
142
181
  export function resolveModelChoice(
143
182
  provider: ApiProvider,
144
183
  input: string,
145
184
  ): string | undefined {
146
185
  const value = input.trim();
147
186
  const lower = value.toLowerCase();
187
+ if (lower === "fast") return resolveModelChoice(provider, FAST_MODEL_ALIAS);
148
188
  const entry = MODEL_CATALOG.find(
149
189
  (row) => row.alias === lower || row.label.toLowerCase() === lower,
150
190
  );
151
191
  if (entry) return entry[provider];
152
192
  if (isGatewayModel(lower)) return DEFAULT_MODEL_IDS[lower];
153
- return MODEL_ID_PATTERN.test(value) ? value : undefined;
193
+ return isProviderModelId(value, provider) ? value : undefined;
154
194
  }
155
195
 
156
196
  /** `anthropic/claude-opus-5.5` → `opus-5.5`; unknown IDs show as themselves. */
@@ -397,7 +437,7 @@ export function parseLiveCatalog(
397
437
  if (
398
438
  !isRecord(row) ||
399
439
  typeof row.id !== "string" ||
400
- !MODEL_ID_PATTERN.test(row.id)
440
+ !isProviderModelId(row.id, provider)
401
441
  )
402
442
  continue;
403
443
  const pricing = isRecord(row.pricing) ? row.pricing : {};
@@ -486,17 +526,18 @@ export async function loadLiveCatalog(
486
526
  * and `chatTools()` serialized, plus `compositionBrief` of a 3-track, 48-note
487
527
  * loop; see models.test.ts, which keeps these in step with the code):
488
528
  *
489
- * - system prompt ≈ 7 KB, tool schemas ≈ 51 KB, brief ≈ 1.6 KB typical
490
- * (capped at 12 KiB), user request ≈ 0.1 KB → ≈ 59 KB ≈ 14,800 tokens
491
- * per request at ~4 bytes per token;
529
+ * - system prompt ≈ 11 KB, tool schemas ≈ 70 KB, brief ≈ 1.6 KB typical
530
+ * (capped at 12 KiB), user request ≈ 0.1 KB → ≈ 83 KB per request. JSON
531
+ * schemas tokenize at about 3 bytes per token: the agent eval
532
+ * (bench/agent-eval) measured 26,500 input tokens per request on Claude;
492
533
  * - about 2 requests per prompt (tool calls, then a short reply), the second
493
534
  * also carrying the first step's tool calls and results (≈ 0.6 KB);
494
535
  * - output ≈ 450 tokens of tool-call arguments and ≈ 150 of reply.
495
536
  *
496
- * So ≈ 29,800 input and ≈ 600 output tokens per prompt.
537
+ * So ≈ 53,000 input and ≈ 600 output tokens per prompt.
497
538
  */
498
539
  export const TYPICAL_PROMPT = Object.freeze({
499
- inputTokens: 29_800,
540
+ inputTokens: 53_000,
500
541
  outputTokens: 600,
501
542
  });
502
543
 
package/src/agent/ops.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { resolveInstrumentWord } from "../../core/instruments.ts";
2
+ import type { TrackVocoder } from "../../core/vocoder.ts";
2
3
  import { pitchToMidi } from "../../core/pitch.ts";
3
4
  import {
4
5
  instrumentPatchForWord,
@@ -6,6 +7,7 @@ import {
6
7
  type TrackWind,
7
8
  } from "../../core/resonators.ts";
8
9
  import { SCORE_LIMITS } from "../../core/score.ts";
10
+ import type { TrackSing } from "../../core/sing.ts";
9
11
 
10
12
  export { pitchToMidi };
11
13
 
@@ -50,6 +52,8 @@ export type AgentOperation =
50
52
  };
51
53
  /** The instrument word as typed (a guitar alias also loads its rig). */
52
54
  word?: string;
55
+ /** `volume drums 0.5`: another track than the focused one. */
56
+ trackId?: string;
53
57
  }
54
58
  | {
55
59
  type: "automation";
@@ -99,6 +103,19 @@ export function parsePrompt(prompt: string): AgentOperation | undefined {
99
103
  return { type: "track", patch: { muted: false } };
100
104
  const volume = text.match(/^(?:volume|vol)\s+(0(?:\.\d+)?|1(?:\.0+)?)$/);
101
105
  if (volume) return { type: "track", patch: { volume: Number(volume[1]) } };
106
+ // `volume drums 0.5` / `pan bass -0.2`: the mixer page's rows name their
107
+ // track, so a level is set without moving the focus.
108
+ const level = text.match(
109
+ /^(volume|vol|pan)\s+([a-z0-9][a-z0-9._-]{0,63})\s+(-?1(?:\.0+)?|-?0(?:\.\d+)?)$/,
110
+ );
111
+ if (level && !(level[1] !== "pan" && level[3]!.startsWith("-"))) {
112
+ const value = Number(level[3]);
113
+ return {
114
+ type: "track",
115
+ patch: level[1] === "pan" ? { pan: value } : { volume: value },
116
+ trackId: level[2]!,
117
+ };
118
+ }
102
119
  const clearAutomation = text.match(
103
120
  /^(?:clear|reset)\s+(?:(volume|pan)\s+)?automation$/,
104
121
  );
@@ -208,10 +225,19 @@ export function instrumentPatch(word: string): {
208
225
  string?: { preset: string };
209
226
  modal?: TrackModal;
210
227
  wind?: TrackWind;
228
+ sing?: TrackSing;
229
+ vocoder?: TrackVocoder;
211
230
  } {
212
231
  const meaning = resolveInstrumentWord(word);
213
232
  if (!meaning) return { instrument: word };
214
- if (meaning.field === "modal" || meaning.field === "wind")
233
+ // f07-vocoder: the built-in carrier needs its (empty) field.
234
+ if (meaning.field === "vocoder")
235
+ return { instrument: meaning.instrument, vocoder: Object.freeze({}) };
236
+ if (
237
+ meaning.field === "modal" ||
238
+ meaning.field === "wind" ||
239
+ meaning.field === "sing"
240
+ )
215
241
  return instrumentPatchForWord(word);
216
242
  if (meaning.field === "string" && meaning.preset)
217
243
  return {
@@ -224,7 +224,7 @@ export const PACK_TOOLS: readonly AgentTool[] = Object.freeze([
224
224
  {
225
225
  name: "use_sound",
226
226
  description:
227
- 'Put a pack sound on a track through the sampler. sound is <pack>/<sound>[:<n>] (n picks a file, like Strudel s("bd:3")), a kit/bank name or Strudel bank nickname (909, 808, linn, TR909, tr808, sp12, dmx, RolandTR909, tidal-drum-machines/RolandTR707) to load a whole drum kit, or a keyed instrument such as gm/gm_acoustic_grand_piano or piano/piano. The file is fetched and pinned by sha256 so renders stay reproducible. Drum hits on the track keep playing the matching kit voice.',
227
+ 'Put a pack sound on a track through the sampler. sound is <pack>/<sound>[:<n>] (n picks a file, like Strudel s("bd:3")), a kit/bank name or Strudel bank nickname (909, 808, linn, TR909, tr808, sp12, dmx, RolandTR909, tidal-drum-machines/RolandTR707) to load a whole kit, or a keyed instrument such as gm/gm_acoustic_grand_piano or piano/piano. The file is fetched and pinned by sha256 so renders stay reproducible. Drum hits on the track keep playing the matching kit voice.',
228
228
  parameters: {
229
229
  type: "object",
230
230
  properties: {
@@ -1,8 +1,10 @@
1
+ import { normalizeSongStyle } from "../../core/style-provenance.ts";
1
2
  import { normalizeSynth } from "../../core/synth.ts";
2
3
  import { normalizeSongTime, normalizeTrackTime } from "../../core/tempo.ts";
3
4
  import { normalizeFx } from "../../core/fx.ts";
4
5
  import { normalizeMaster } from "../../core/master.ts";
5
6
  import {
7
+ CALIBRATION_LATEST,
6
8
  SCORE_LIMITS,
7
9
  automationRange,
8
10
  isAutomationParameter,
@@ -142,9 +144,20 @@ function parseOperation(value: unknown): ScoreOperation {
142
144
  const time = value.time === null ? null : normalizeSongTime(value.time);
143
145
  return { type: "setTime", time: time ?? null };
144
146
  }
147
+ if (
148
+ value.type === "setCalibration" &&
149
+ (value.calibration === null ||
150
+ (typeof value.calibration === "number" &&
151
+ Number.isInteger(value.calibration) &&
152
+ value.calibration >= 0 &&
153
+ value.calibration <= CALIBRATION_LATEST))
154
+ )
155
+ return { type: "setCalibration", calibration: value.calibration };
145
156
  // The master reuses its bounded validator; null removes it.
146
157
  if (value.type === "setMaster" && value.master !== undefined)
147
158
  return { type: "setMaster", master: normalizeMaster(value.master) ?? null };
159
+ if (value.type === "setStyle" && value.style !== undefined)
160
+ return { type: "setStyle", style: normalizeSongStyle(value.style) ?? null };
148
161
  if (
149
162
  value.type === "setSections" &&
150
163
  Array.isArray(value.sections) &&
@@ -165,6 +178,18 @@ function parseOperation(value: unknown): ScoreOperation {
165
178
  loopSection: (value.loopSection ?? null) as string | null,
166
179
  };
167
180
  }
181
+ if (
182
+ value.type === "setLoop" &&
183
+ (value.loop === null ||
184
+ (isRecord(value.loop) &&
185
+ typeof value.loop.startBar === "number" &&
186
+ typeof value.loop.bars === "number"))
187
+ )
188
+ // The reducer checks the range (TrackScore normalizeLoopRange).
189
+ return {
190
+ type: "setLoop",
191
+ loop: value.loop as { startBar: number; bars: number } | null,
192
+ };
168
193
  if (
169
194
  value.type === "updateTrack" &&
170
195
  typeof value.trackId === "string" &&