mlx-bun 0.4.0 → 0.5.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 (158) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +55 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  5. package/docs/design/batching.md +220 -7
  6. package/docs/design/decode-speed-program.md +121 -66
  7. package/docs/design/generic-model-support.md +116 -0
  8. package/docs/design/kv-cache.md +380 -211
  9. package/docs/design/speculative-decoding.md +165 -26
  10. package/docs/design/turboquant.md +20 -7
  11. package/docs/design/unified-engine-frontier-plan.md +120 -13
  12. package/docs/planning/PRODUCT_ROADMAP.md +5 -6
  13. package/docs/planning/public-readiness-handoff.md +244 -0
  14. package/docs/planning/public-readiness.md +149 -0
  15. package/docs/reference/benchmarks.md +3463 -3489
  16. package/docs/reference/cli.md +62 -1
  17. package/docs/reference/distribution.md +29 -4
  18. package/docs/reference/environment.md +104 -8
  19. package/docs/reference/library-api.md +18 -4
  20. package/docs/reference/memory.md +1 -1
  21. package/docs/reference/models.md +17 -5
  22. package/docs/reference/server-api.md +181 -11
  23. package/docs/reference/server-config.md +232 -105
  24. package/package.json +2 -2
  25. package/src/audio/audiotoolbox.ts +112 -0
  26. package/src/audio/conformer.ts +8 -0
  27. package/src/audio/mic-capture.ts +98 -0
  28. package/src/audio/silero-vad.ts +379 -0
  29. package/src/audio/whisper-decode.ts +774 -0
  30. package/src/audio/whisper-fast.ts +732 -0
  31. package/src/audio/whisper-format.ts +73 -0
  32. package/src/audio/whisper-mel.ts +185 -0
  33. package/src/audio/whisper-timing.ts +274 -0
  34. package/src/audio/whisper-tokenizer.ts +239 -0
  35. package/src/audio/whisper-transcribe.ts +410 -0
  36. package/src/backends/mlx/autoregressive.ts +22 -1
  37. package/src/backends/mlx/batch-group.ts +111 -27
  38. package/src/backends/mlx/cache-layout.ts +3 -3
  39. package/src/backends/mlx/continuation-persistence.ts +5 -4
  40. package/src/backends/mlx/encoder-cache.ts +25 -0
  41. package/src/backends/mlx/fill-append.ts +21 -8
  42. package/src/backends/mlx/fill-group.ts +497 -0
  43. package/src/backends/mlx/gateway-binding.ts +56 -12
  44. package/src/backends/mlx/kv-maintenance.ts +30 -2
  45. package/src/backends/mlx/mixed-iteration.ts +62 -0
  46. package/src/backends/mlx/model-prompt.ts +46 -32
  47. package/src/backends/mlx/model-serving.ts +4 -3
  48. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  49. package/src/backends/mlx/prefill-cohort.ts +15 -4
  50. package/src/backends/mlx/prefill-policy.ts +26 -0
  51. package/src/backends/mlx/prefill-rows.ts +57 -13
  52. package/src/backends/mlx/prompt-input.ts +29 -0
  53. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  54. package/src/backends/mlx/request-state-policy.ts +18 -5
  55. package/src/backends/mlx/rollback.ts +6 -3
  56. package/src/backends/mlx/serial-executor.ts +16 -7
  57. package/src/backends/mlx/speculative-group.ts +105 -26
  58. package/src/backends/mlx/speculative.ts +24 -7
  59. package/src/backends/mlx/target-layout-capability.ts +1 -1
  60. package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
  61. package/src/cli.ts +322 -0
  62. package/src/contracts/execution.ts +10 -0
  63. package/src/contracts/object-cache.ts +8 -0
  64. package/src/contracts/pi-provider.ts +1 -1
  65. package/src/contracts/pi.ts +3 -1
  66. package/src/contracts/prefix-cache.ts +13 -3
  67. package/src/contracts/scheduling.ts +9 -0
  68. package/src/engine/execution-plan.ts +23 -12
  69. package/src/engine/execution-tasks.ts +44 -0
  70. package/src/engine/namespaced-cache.ts +13 -0
  71. package/src/engine/scheduler.ts +22 -2
  72. package/src/fill/fill-session.ts +14 -3
  73. package/src/generate.ts +28 -22
  74. package/src/grammar.ts +13 -0
  75. package/src/inference/prefill.ts +6 -0
  76. package/src/kv-scheme.ts +22 -0
  77. package/src/kv-store.ts +115 -79
  78. package/src/lab/README.md +5 -1
  79. package/src/lab/paged-kv/paged-attention.ts +117 -0
  80. package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
  81. package/src/lab/paged-kv/paged-kv.ts +108 -42
  82. package/src/md-modules.d.ts +5 -0
  83. package/src/mlx/array.ts +37 -0
  84. package/src/mlx/ffi.ts +19 -0
  85. package/src/mlx/normalized-argmax.ts +116 -0
  86. package/src/mlx/ops.ts +97 -10
  87. package/src/model/batched-turboquant-kv.ts +6 -5
  88. package/src/model/delayed-quantized-kv.ts +3 -3
  89. package/src/model/delayed-rotating-quantized-kv.ts +1 -1
  90. package/src/model/delayed-turboquant-kv.ts +6 -4
  91. package/src/model/factory.ts +26 -0
  92. package/src/model/fingerprint.ts +12 -0
  93. package/src/model/full-prefill-row.ts +1 -1
  94. package/src/model/gemma4-base.ts +25 -6
  95. package/src/model/gemma4.ts +82 -2
  96. package/src/model/kv-attention-view.ts +4 -1
  97. package/src/model/profile.ts +29 -4
  98. package/src/model/quantized-append-attention.ts +31 -0
  99. package/src/model/qwen3-mrope.ts +7 -9
  100. package/src/model/qwen3_5.ts +99 -37
  101. package/src/model/support.ts +8 -0
  102. package/src/model/token-groups.ts +66 -0
  103. package/src/model/trellis-linear.ts +2 -2
  104. package/src/model/turboquant-codec.ts +6 -0
  105. package/src/model/whisper.ts +435 -0
  106. package/src/native/mic_capture.swift +92 -0
  107. package/src/native-pack.ts +6 -5
  108. package/src/pi-web.ts +9 -1
  109. package/src/prompt-cache.ts +265 -43
  110. package/src/runtime-config.ts +1 -5
  111. package/src/safetensors.ts +9 -2
  112. package/src/sampler.ts +5 -4
  113. package/src/serve/audio-routes.ts +323 -0
  114. package/src/serve/chat-request.ts +2 -0
  115. package/src/serve/chat-stage.ts +17 -12
  116. package/src/serve/completion-executor.ts +9 -1
  117. package/src/serve/discovery-routes.ts +17 -1
  118. package/src/serve/generated-token-history.ts +57 -0
  119. package/src/serve/generation-gateway.ts +52 -11
  120. package/src/serve/model-binding.ts +7 -1
  121. package/src/serve/preparation.ts +3 -3
  122. package/src/serve/prompt-response-trace.ts +14 -0
  123. package/src/serve/request-plan.ts +2 -0
  124. package/src/serve/request-prep.ts +38 -21
  125. package/src/serve/transcription-server.ts +64 -0
  126. package/src/serve/transcription-service.ts +502 -0
  127. package/src/server.ts +132 -164
  128. package/src/spec/assistant-rows.ts +1 -0
  129. package/src/spec/deepspec-rows.ts +1 -1
  130. package/src/spec/dflash-source.ts +1 -1
  131. package/src/spec/glm52-mtp-source.ts +1 -0
  132. package/src/spec/ngram-source.ts +27 -5
  133. package/src/spec/projected-draft-rows.ts +2 -2
  134. package/src/spec/qwen-mtp-rows.ts +11 -0
  135. package/src/spec/qwen-mtp-source.ts +3 -0
  136. package/src/spec/serve-loop.ts +5 -3
  137. package/src/spec/source.ts +14 -1
  138. package/src/spec/two-model-rows.ts +1 -0
  139. package/src/spec/two-model.ts +2 -2
  140. package/src/ssd-cache.ts +76 -17
  141. package/src/ssd-durability.ts +20 -22
  142. package/src/storage/host-buffer.ts +24 -0
  143. package/src/storage/kv-writer.ts +83 -0
  144. package/src/storage/kv-writer.worker.js +264 -0
  145. package/src/storage/retention-policy.ts +37 -0
  146. package/src/tiered-prompt-cache.ts +45 -0
  147. package/src/vision/embedder.ts +8 -0
  148. package/src/vision/prompt.ts +101 -65
  149. package/src/vision/qwen3vl-prompt.ts +101 -64
  150. package/src/vision/qwen3vl-tower.ts +11 -0
  151. package/src/vision/siglip.ts +8 -0
  152. package/src/web/app.html +12 -0
  153. package/src/web/app.js +160 -0
  154. package/src/web/src/chat.ts +4 -1
  155. package/src/web/src/composer.ts +3 -0
  156. package/src/web/src/voice.ts +141 -0
  157. package/src/web/sw.js +1 -1
  158. /package/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
package/src/cli.ts CHANGED
@@ -74,6 +74,8 @@ Commands:
74
74
  generate One-shot raw text generation from a local model (no server)
75
75
  benchmark Measure decode/prefill speed of OUR stack on this machine
76
76
  embed Text embeddings from a local embedding model (Qwen3-Embedding)
77
+ transcribe Speech-to-text from an audio file with a local Whisper model
78
+ dictate Push-to-talk: stream your microphone into Whisper, print/copy/type the text
77
79
  evals Show recent benchmark runs (all stacks)
78
80
  help Show help for a command (also: mlx-bun <command> --help)
79
81
 
@@ -164,6 +166,19 @@ Model & quality:
164
166
  request's \`adapter\` field, incl. "none", wins).
165
167
  --adapter-path is accepted as the mlx_lm.server
166
168
  alias. Hot-swap via POST /v1/adapters unchanged.
169
+ --whisper-model <p|query> Whisper checkpoint for /v1/audio/transcriptions
170
+ [default: the first downloaded whisper model,
171
+ resolved on the first request]. Serving a whisper
172
+ model ALONE starts a transcription-only server.
173
+ --preload Transcription-only server: load the Whisper
174
+ weights before listening instead of on the first
175
+ request.
176
+ --whisper-idle-unload <s> Keep the Whisper weights for this many idle seconds
177
+ after a take before disposing them. [default: 0 —
178
+ released right after every take, so the chat model
179
+ has the memory back before it prefills; a cold
180
+ take costs ~150 ms more than a warm one]
181
+ --whisper-resident Never dispose the Whisper weights (sotto-style).
167
182
  --draft-model <query> Speculative decoding: a drafter proposes tokens
168
183
  the main model verifies (exact results, faster
169
184
  decode when drafts land). The artifact's kind is
@@ -268,6 +283,74 @@ Kill switches (bit-exact A/B levers — each selects a slower same-parity path):
268
283
  bit-exact with the resident path`;
269
284
 
270
285
  const HELP: Record<string, string> = {
286
+ dictate: `mlx-bun dictate — push-to-talk dictation from your microphone
287
+
288
+ Usage: mlx-bun dictate [query] [options]
289
+
290
+ Captures the mic through the AVAudioEngine sidecar (16 kHz mono; macOS asks
291
+ for Microphone permission on first use) and streams it into a Whisper
292
+ transcription session while you speak — every finished 30 s window is
293
+ transcribed during capture, so the result lands about one window after you
294
+ stop. Silence never runs Whisper (Silero VAD).
295
+
296
+ Modes:
297
+ (default) Press Enter to start a take, Enter again to stop;
298
+ q + Enter or Ctrl-C quits
299
+ --hotkey [keycode] Hold a key instead (default 61 = Right Option, like
300
+ sotto); needs Input Monitoring for your terminal
301
+ Backend:
302
+ [query] / --model <p> Whisper model for in-process transcription (default:
303
+ the first downloaded whisper checkpoint)
304
+ --server <url> Use a running mlx-bun server's /v1/audio/sessions
305
+ instead of loading the model here
306
+ --idle-unload <s> Release the weights after this many idle seconds
307
+ (0 = right after each take; default 30); --resident
308
+ keeps them loaded
309
+ Decoding:
310
+ --language <code> default en; "auto" detects
311
+ --beam-size <n> beam search (default greedy)
312
+ --prompt "…" initial prompt / --vocabulary "a,b,c" hint terms
313
+ --no-vad skip the Silero gate
314
+ Output (any combination; default prints):
315
+ --copy copy the transcript to the clipboard (pbcopy)
316
+ --type type it into the frontmost app via System Events
317
+ keystrokes (needs Accessibility for your terminal);
318
+ --type-delay <s> waits first so you can Cmd-Tab
319
+ (default 1 in Enter mode, 0 with --hotkey)`,
320
+ transcribe: `mlx-bun transcribe — speech-to-text with a local Whisper model (no server)
321
+
322
+ Usage: mlx-bun transcribe <audio-file> [query] [options]
323
+
324
+ Loads a Whisper checkpoint (mlx-community/whisper-*) and prints the
325
+ transcript. WAV is decoded natively; any other container CoreAudio reads
326
+ (mp3/m4a/flac/ogg/aiff/…) is transcoded with afconvert first.
327
+
328
+ Model:
329
+ [query] / --model <p> Whisper model; omitted = the first downloaded
330
+ \`whisper\` checkpoint (errors with an mlx-bun get
331
+ suggestion when none)
332
+ Decoding:
333
+ --language <code> ISO code or name ("en", "japanese"); "auto" or omitted
334
+ detects the language from the first 30 s
335
+ --task translate Translate to English instead of transcribing
336
+ --beam-size <n> Beam search width (default: greedy)
337
+ --temperature <t> Sampling temperature; the default runs the
338
+ (0, 0.2, …, 1.0) fallback ladder like mlx-whisper
339
+ --no-fallback Temperature 0 only, no retry ladder
340
+ --prompt "…" Initial prompt (vocabulary hints / style)
341
+ --no-timestamps Decode text only (<|notimestamps|>)
342
+ --no-condition Do not condition each window on the previous text
343
+ --vad Silero VAD gate: skip Whisper when no speech is
344
+ detected (weights: ggml-org/whisper-vad in the HF cache
345
+ or --vad-model <path>); --vad-trim crops to speech
346
+ --word-timestamps Word-level timestamps (verbose_json / verbose output)
347
+ --faithful Run the oracle-parity graph instead of the fast path
348
+ --audio-ctx <n> Lab: encode only n of the 1500 encoder positions
349
+ (whisper.cpp -ac); cheaper for short clips, degrades
350
+ below ~1024 — off by default
351
+ Output:
352
+ --format <f> text (default) · json · verbose_json · srt · vtt
353
+ --verbose Print each segment with its time range as it decodes`,
271
354
  pi: `mlx-bun pi — drop into a coding-agent session on a local model
272
355
 
273
356
  Usage: mlx-bun pi [options] [message...]
@@ -1023,6 +1106,17 @@ function serverRuntimeFlags(): { port: number; serverOptions: import("./server")
1023
1106
  // `mlx-bun pi` attach both go through localhost, so loopback-only is
1024
1107
  // transparent to them.
1025
1108
  serverOptions.hostname = opt("host", "127.0.0.1")!;
1109
+ // Speech-to-text companion: explicit checkpoint + residency policy.
1110
+ const whisperIdleRaw = opt("whisper-idle-unload");
1111
+ if (whisperIdleRaw !== null) {
1112
+ const n = Number(whisperIdleRaw);
1113
+ if (!Number.isFinite(n) || n < 0) {
1114
+ console.error(`--whisper-idle-unload expects seconds >= 0 (got "${whisperIdleRaw}")`);
1115
+ process.exit(1);
1116
+ }
1117
+ serverOptions.whisperIdleUnloadSec = n;
1118
+ }
1119
+ if (flag("whisper-resident")) serverOptions.whisperResident = true;
1026
1120
  // Server-wide default for the chat template's enable_thinking variable
1027
1121
  // (CPM/MiniCPM5 and other hybrid-reasoning models). Unset ⇒ the model's
1028
1122
  // own default (false for MiniCPM5). onOff accepts true|false|on|off|1|0.
@@ -1637,6 +1731,40 @@ switch (cmd) {
1637
1731
  // straight from that path; anything else resolves like the positional
1638
1732
  // fuzzy query. Precedence: --model > positional > --query > auto-pick.
1639
1733
  const { m, picked } = await resolveModelAuto(opt("model") ?? positional(0) ?? opt("query"));
1734
+ if (m.modelType === "whisper") {
1735
+ // Transcription-only server: no chat model, weights paged in on demand.
1736
+ const sNative0 = step("native runtime");
1737
+ await ensureNative(sNative0);
1738
+ sNative0.done("native runtime ready");
1739
+ const { createTranscriptionServer } = await import("./serve/transcription-server");
1740
+ const { server, service } = await createTranscriptionServer({
1741
+ modelDir: m.path, modelId: m.repoId, port: rt.port,
1742
+ ...(rt.serverOptions.hostname ? { hostname: rt.serverOptions.hostname } : {}),
1743
+ ...(rt.serverOptions.unixSocket ? { unixSocket: rt.serverOptions.unixSocket } : {}),
1744
+ idleUnloadSec: rt.serverOptions.whisperIdleUnloadSec,
1745
+ resident: rt.serverOptions.whisperResident,
1746
+ preload: flag("preload"),
1747
+ });
1748
+ const where = rt.serverOptions.unixSocket ?? `http://${rt.serverOptions.hostname === "0.0.0.0" ? "localhost" : rt.serverOptions.hostname}:${server.port}`;
1749
+ box([
1750
+ `${style.bold(m.repoId)} ${style.dim("· transcription-only server")}`,
1751
+ `POST ${where}/v1/audio/transcriptions ${style.dim(`· ${service.stats.idle_unload_sec === null ? "always resident" : service.idleUnloadSec === 0 ? "released after every take" : `idle unload ${service.idleUnloadSec}s`}${service.resident ? " · loaded" : " · loads on first request"}`)}`,
1752
+ ]);
1753
+ const stop = () => { service.dispose(); server.stop(true); process.exit(0); };
1754
+ process.once("SIGINT", stop);
1755
+ process.once("SIGTERM", stop);
1756
+ break;
1757
+ }
1758
+ const whisperQuery = opt("whisper-model");
1759
+ if (whisperQuery !== null) {
1760
+ const { m: wm } = await resolveModelAuto(whisperQuery);
1761
+ if (wm.modelType !== "whisper") {
1762
+ console.error(`--whisper-model ${whisperQuery} resolved to ${wm.repoId} (model_type ${wm.modelType}), not a Whisper checkpoint`);
1763
+ process.exit(1);
1764
+ }
1765
+ rt.serverOptions.whisperModelDir = wm.path;
1766
+ rt.serverOptions.whisperModelId = wm.repoId;
1767
+ }
1640
1768
  const sFit = step("assessing fit");
1641
1769
  const config = await loadModelConfig(m.path);
1642
1770
  const report = fit(config, m.sizeBytes, 8192, undefined, undefined, m.expertsBytes);
@@ -1883,6 +2011,200 @@ switch (cmd) {
1883
2011
  break;
1884
2012
  }
1885
2013
 
2014
+ case "transcribe": {
2015
+ // Speech-to-text, one-shot, no server: audio file → Whisper → text.
2016
+ const { openWhisperModel } = await import("./model/factory");
2017
+ const { loadWhisperTokenizer } = await import("./audio/whisper-tokenizer");
2018
+ const { WhisperTranscriber } = await import("./audio/whisper-transcribe");
2019
+ const { formatTranscription, TRANSCRIPTION_FORMATS } = await import("./audio/whisper-format");
2020
+ const { TranscriptionService } = await import("./serve/transcription-service");
2021
+ const { formatTimestamp } = await import("./audio/whisper-format");
2022
+
2023
+ const audioPath = positional(0);
2024
+ if (!audioPath) {
2025
+ console.error("usage: mlx-bun transcribe <audio-file> [query] [--language en] [--format text|json|verbose_json|srt|vtt]");
2026
+ process.exit(1);
2027
+ }
2028
+ const format = (opt("format") ?? "text") as import("./audio/whisper-format").TranscriptionFormat;
2029
+ if (!TRANSCRIPTION_FORMATS.includes(format)) {
2030
+ console.error(`--format must be one of ${TRANSCRIPTION_FORMATS.join(", ")}`);
2031
+ process.exit(1);
2032
+ }
2033
+ const bytes = new Uint8Array(await Bun.file(audioPath).arrayBuffer());
2034
+ const samples = await TranscriptionService.decodeAudio(bytes);
2035
+ const durationSeconds = samples.length / 16_000;
2036
+
2037
+ const modelQuery = opt("model") ?? positional(1) ?? opt("query");
2038
+ let modelDir: string;
2039
+ if (modelQuery && existsSync(join(modelQuery, "config.json"))) modelDir = modelQuery;
2040
+ else if (modelQuery) modelDir = (await resolveModelAuto(modelQuery)).m.path;
2041
+ else {
2042
+ const reg = new Registry();
2043
+ if (reg.list().length === 0) await reg.scan();
2044
+ const w = reg.list().find((r) => r.modelType === "whisper");
2045
+ if (!w) {
2046
+ console.error("no Whisper model downloaded — try: mlx-bun get mlx-community/whisper-large-v3-turbo");
2047
+ process.exit(1);
2048
+ }
2049
+ modelDir = w.path;
2050
+ }
2051
+ let vadGate: { segments: { start: number; end: number }[] } | null = null;
2052
+ if (flag("vad")) {
2053
+ const { SileroVad } = await import("./audio/silero-vad");
2054
+ const tv = performance.now();
2055
+ const vad = SileroVad.load(opt("vad-model"));
2056
+ const { segments } = vad.detect(samples, { threshold: Number(opt("vad-threshold") ?? 0.5) });
2057
+ vadGate = { segments };
2058
+ if (flag("verbose")) console.error(`vad: ${segments.length} speech segment(s) in ${(performance.now() - tv).toFixed(0)} ms`);
2059
+ if (segments.length === 0) { process.stdout.write(format === "text" ? "\n" : JSON.stringify({ text: "", vad: { speech: false, segments: [] } }) + "\n"); break; }
2060
+ }
2061
+ const { model } = await openWhisperModel(modelDir);
2062
+ const tok = await loadWhisperTokenizer(modelDir, model.dims.nVocab);
2063
+ const transcriber = new WhisperTranscriber(model, tok);
2064
+ void vadGate;
2065
+ const langOpt = opt("language");
2066
+ const task = opt("task") === "translate" ? "translate" as const : "transcribe" as const;
2067
+ const beam = opt("beam-size");
2068
+ const temperature = opt("temperature");
2069
+ const verbose = flag("verbose");
2070
+ const t0 = performance.now();
2071
+ const result = await transcriber.transcribe(samples, {
2072
+ language: !langOpt || langOpt === "auto" ? null : langOpt,
2073
+ task,
2074
+ beamSize: beam ? Number(beam) : null,
2075
+ ...(temperature ? { temperature: Number(temperature) } : flag("no-fallback") ? { temperature: 0 } : {}),
2076
+ initialPrompt: opt("prompt"),
2077
+ withoutTimestamps: flag("no-timestamps"),
2078
+ conditionOnPreviousText: !flag("no-condition"),
2079
+ fast: !flag("faithful"),
2080
+ wordTimestamps: flag("word-timestamps"),
2081
+ audioCtx: opt("audio-ctx") ? Number(opt("audio-ctx")) : null,
2082
+ onSegment: verbose
2083
+ ? (s) => console.error(`[${formatTimestamp(s.start, ".")} --> ${formatTimestamp(s.end, ".")}] ${s.text.trim()}`)
2084
+ : undefined,
2085
+ });
2086
+ const elapsed = (performance.now() - t0) / 1000;
2087
+ if (verbose)
2088
+ console.error(`${durationSeconds.toFixed(2)} s audio in ${elapsed.toFixed(3)} s (${(durationSeconds / elapsed).toFixed(1)}× realtime), language ${result.language}`);
2089
+ process.stdout.write(formatTranscription(result, format, durationSeconds, task));
2090
+ transcriber.dispose();
2091
+ model.dispose();
2092
+ break;
2093
+ }
2094
+
2095
+ case "dictate": {
2096
+ const { startMicCapture } = await import("./audio/mic-capture");
2097
+ const { TranscriptionService, defaultWhisperRecord } = await import("./serve/transcription-service");
2098
+ const { style } = await import("./tui");
2099
+ const serverUrl = opt("server");
2100
+ const hotkeyFlag = argv.includes("--hotkey");
2101
+ const hotkey = hotkeyFlag ? Number(opt("hotkey") ?? 61) || 61 : null;
2102
+ const language = opt("language") ?? "en";
2103
+ const beam = opt("beam-size");
2104
+ const vocabulary = opt("vocabulary")?.split(",").map((s) => s.trim()).filter(Boolean);
2105
+ const params = {
2106
+ language: language === "auto" ? null : language,
2107
+ beamSize: beam ? Number(beam) : null,
2108
+ temperature: 0,
2109
+ prompt: opt("prompt"),
2110
+ vocabulary,
2111
+ vad: flag("no-vad") ? null : { threshold: 0.5, minSpeechMs: 120 },
2112
+ };
2113
+ const typeDelay = Number(opt("type-delay") ?? (hotkey ? 0 : 1));
2114
+
2115
+ // backend: in-process service or a running server's sessions
2116
+ let service: InstanceType<typeof TranscriptionService> | null = null;
2117
+ if (!serverUrl) {
2118
+ const q = opt("model") ?? positional(0) ?? opt("query");
2119
+ let modelDir: string; let modelId: string;
2120
+ if (q && existsSync(join(q, "config.json"))) { modelDir = q; modelId = q; }
2121
+ else if (q) { const { m } = await resolveModelAuto(q); modelDir = m.path; modelId = m.repoId; }
2122
+ else {
2123
+ const rec = await defaultWhisperRecord();
2124
+ if (!rec) { console.error("no Whisper model downloaded — try: mlx-bun get mlx-community/whisper-large-v3-turbo"); process.exit(1); }
2125
+ modelDir = rec.path; modelId = rec.repoId;
2126
+ }
2127
+ const idle = opt("idle-unload");
2128
+ service = new TranscriptionService({ modelDir, modelId, idleUnloadSec: idle !== null ? Number(idle) : 30, resident: flag("resident"), log: () => {} });
2129
+ process.stderr.write(style.dim("loading whisper… "));
2130
+ await service.ensureLoaded();
2131
+ service.vad();
2132
+ process.stderr.write(style.dim("ready\n"));
2133
+ }
2134
+ const base = serverUrl?.replace(/\/$/, "");
2135
+ interface Take { feed(pcm: Float32Array): Promise<void>; finish(): Promise<{ text: string; ms: number }>; }
2136
+ const startTake = async (): Promise<Take> => {
2137
+ if (service) {
2138
+ const session = await service.createSession(params);
2139
+ return {
2140
+ feed: async (pcm) => { await session.append(pcm); },
2141
+ finish: async () => { const t0 = performance.now(); const o = await session.finish(); return { text: o.result.text.trim(), ms: performance.now() - t0 }; },
2142
+ };
2143
+ }
2144
+ const r = await fetch(`${base}/v1/audio/sessions`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ language: params.language, beam_size: params.beamSize, temperature: 0, prompt: params.prompt, vocabulary, vad: !!params.vad, vad_min_speech_ms: 120 }) });
2145
+ if (!r.ok) throw new Error(`session create failed: ${r.status} ${await r.text()}`);
2146
+ const { id } = await r.json() as { id: string };
2147
+ return {
2148
+ feed: async (pcm) => { await fetch(`${base}/v1/audio/sessions/${id}/audio`, { method: "POST", headers: { "content-type": "audio/pcm;rate=16000" }, body: new Uint8Array(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + pcm.byteLength) as ArrayBuffer) }); },
2149
+ finish: async () => { const t0 = performance.now(); const f = await fetch(`${base}/v1/audio/sessions/${id}/finish`, { method: "POST" }); const b = await f.json() as { text: string }; return { text: (b.text ?? "").trim(), ms: performance.now() - t0 }; },
2150
+ };
2151
+ };
2152
+
2153
+ const deliver = async (text: string) => {
2154
+ if (!text) { console.log(style.dim("(no speech)")); return; }
2155
+ console.log(text);
2156
+ if (flag("copy")) { const p = Bun.spawn(["pbcopy"], { stdin: "pipe" }); p.stdin.write(text); p.stdin.end(); await p.exited; }
2157
+ if (flag("type")) {
2158
+ if (typeDelay > 0) await Bun.sleep(typeDelay * 1000);
2159
+ const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2160
+ const r = Bun.spawnSync(["osascript", "-e", `tell application "System Events" to keystroke "${escaped}"`], { stderr: "pipe" });
2161
+ if (r.exitCode !== 0) console.error(style.dim(`typing failed (grant Accessibility to your terminal): ${r.stderr.toString().trim()}`));
2162
+ }
2163
+ };
2164
+
2165
+ const mic = await startMicCapture({ hotkey });
2166
+ let take: Take | null = null;
2167
+ let pending: Float32Array[] = []; let pendingLen = 0;
2168
+ let feeding: Promise<void> = Promise.resolve();
2169
+ const flush = () => {
2170
+ if (!take || pendingLen === 0) return;
2171
+ const buf = new Float32Array(pendingLen); let o = 0; for (const p of pending) { buf.set(p, o); o += p.length; }
2172
+ pending = []; pendingLen = 0;
2173
+ const t = take;
2174
+ feeding = feeding.then(() => t.feed(buf)).catch((e) => console.error(style.dim(`feed failed: ${(e as Error).message}`)));
2175
+ };
2176
+ const begin = async () => { if (take) return; take = await startTake(); pending = []; pendingLen = 0; process.stderr.write(style.bold("● recording") + style.dim(hotkey ? " (release to stop)\n" : " (Enter to stop)\n")); };
2177
+ const end = async () => {
2178
+ if (!take) return;
2179
+ flush();
2180
+ await feeding;
2181
+ const t = take; take = null;
2182
+ process.stderr.write(style.dim("transcribing… "));
2183
+ try { const { text, ms } = await t.finish(); process.stderr.write(style.dim(`${ms.toFixed(0)} ms\n`)); await deliver(text); }
2184
+ catch (e) { console.error(`transcription failed: ${(e as Error).message}`); }
2185
+ };
2186
+ if (!hotkey) {
2187
+ console.error(style.dim("Enter: start/stop a take · q: quit"));
2188
+ (async () => {
2189
+ for await (const line of console) {
2190
+ if (line.trim() === "q") { mic.stop(); process.exit(0); }
2191
+ if (take) await end(); else await begin();
2192
+ }
2193
+ })();
2194
+ }
2195
+ process.once("SIGINT", () => { mic.stop(); process.exit(0); });
2196
+ for await (const ev of mic.events) {
2197
+ if (ev.kind === "ready") { if (hotkey) console.error(style.dim(`hold key ${hotkey} to talk · Ctrl-C quits · mic ${ev.info}`)); }
2198
+ else if (ev.kind === "error") { console.error(ev.message); mic.stop(); process.exit(1); }
2199
+ else if (ev.kind === "hotkey") { if (ev.down) await begin(); else await end(); }
2200
+ else if (ev.kind === "pcm" && take) {
2201
+ pending.push(ev.samples); pendingLen += ev.samples.length;
2202
+ if (pendingLen >= 16_000 / 4) flush(); // every 250 ms of audio
2203
+ }
2204
+ }
2205
+ break;
2206
+ }
2207
+
1886
2208
  case "bench":
1887
2209
  case "benchmark": {
1888
2210
  const { banner, step, box, style } = await import("./tui");
@@ -1,6 +1,7 @@
1
1
  /** Request facts only. Native resources remain with the preparation owner. */
2
2
  export interface ExecutionRequirements {
3
3
  readonly hasVision: boolean;
4
+ readonly hasPreparedPrefixIdentity?: boolean;
4
5
  readonly hasAdapters: boolean;
5
6
  readonly hasRepetitionPenalty: boolean;
6
7
  readonly userSeed: boolean;
@@ -18,6 +19,9 @@ export interface ExecutionCapabilities {
18
19
  readonly quantizedBatch: boolean;
19
20
  readonly grammarBatch: boolean;
20
21
  readonly adapterBatch?: boolean;
22
+ /** Prepared media can enter the ordinary shared execution group. */
23
+ readonly mediaBatch?: boolean;
24
+ readonly mediaPrefixCache?: boolean;
21
25
  readonly pagedBatch?: boolean;
22
26
  readonly checkpoints: boolean;
23
27
  /** Bound ordinary driver can restore/capture this request configuration. */
@@ -31,6 +35,12 @@ export interface ExecutionCapabilities {
31
35
  readonly sharedSpeculativeAdapters?: boolean;
32
36
  /** Methods supplied by the model's shared execution binding. */
33
37
  readonly groupedMethods?: readonly string[];
38
+ /** A shared method accepts request-owned grammar continuation proposals. */
39
+ readonly sharedGrammarProposals?: boolean;
40
+ /** A grouped method can consume this request's strict known continuations. */
41
+ readonly sharedFill?: boolean;
42
+ /** A mounted provider can consume verified external continuations. */
43
+ readonly sharedSpeculativeEcho?: boolean;
34
44
  /** A graph-owned compiled step exists; cache geometry can still decline it. */
35
45
  readonly compiledDecode?: boolean;
36
46
  }
@@ -0,0 +1,8 @@
1
+ /** Exact reusable work, independent of token-prefix matching. The same cache
2
+ * owner chooses residency and persistence for objects and conversation state. */
3
+ export interface ObjectCache<Value> {
4
+ /** Borrow immutable data through an owned lease. A miss permits recompute. */
5
+ take(key: string): Promise<{ value: Value; dispose(): void } | null>;
6
+ /** Successful return transfers ownership, including immediate eviction. */
7
+ put(key: string, value: Value): void;
8
+ }
@@ -13,7 +13,7 @@ export function piModelDefinition(options: PiModelOptions = {}) {
13
13
  name: options.name ?? "mlx-bun (local)",
14
14
  api: "openai-completions" as const,
15
15
  reasoning: options.reasoning ?? false,
16
- compat: { supportsDeveloperRole: false,
16
+ compat: { supportsDeveloperRole: false, sendSessionAffinityHeaders: true,
17
17
  ...(options.reasoning ? { thinkingFormat: "qwen-chat-template" as const } : {}) },
18
18
  input: (options.vision ? ["text", "image"] : ["text"]) as ("text" | "image")[],
19
19
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -160,7 +160,9 @@ export type ServerMessage =
160
160
  // provider declaration — clients use it instead of probing for a 400.
161
161
  // `thinking`: whether the model has a switchable reasoning channel (drives
162
162
  // the UI's thinking on/off toggle; false hides it).
163
- | { type: "ready"; model: string; vision: boolean; audio: boolean; thinking: boolean; genDefaults: ReadyGenDefaults }
163
+ // transcription: a Whisper checkpoint is configured or resolvable for
164
+ // /v1/audio/sessions — drives the composer's hold-to-talk mic button.
165
+ | { type: "ready"; model: string; vision: boolean; audio: boolean; thinking: boolean; genDefaults: ReadyGenDefaults; transcription?: boolean }
164
166
  | { type: "turn_start" }
165
167
  | { type: "text_delta"; delta: string }
166
168
  | { type: "thinking_delta"; delta: string }
@@ -13,11 +13,21 @@ export interface PrefixCacheHit<State, Attachment = never> {
13
13
  * State is opaque here; its backend/method defines layout and alignment.
14
14
  * Implementations own prefix selection, retention and storage tiers. */
15
15
  export interface PrefixCache<State, Attachment = never> {
16
- /** Return owned state for the longest usable prefix, or null on a miss.
16
+ /** Give optional retained state back to the allocator at an execution
17
+ * boundary. This never rejects or changes the active request. */
18
+ reclaim?(): void;
19
+ /** Drop session affinity without deleting reusable checkpoints. */
20
+ closeSession?(sessionId: string): void;
21
+ /** Prepare a reusable prefix before execution. The returned release ends
22
+ * request interest; cache residency and IO remain implementation-owned. */
23
+ prefetch?(prompt: number[], ns?: string, sessionId?: string): Promise<() => void>;
24
+ /** Return owned state for a usable session continuation, otherwise the
25
+ * longest usable prefix, or null on a miss. Session identity is an affinity
26
+ * hint and never changes the content or numerical namespace.
17
27
  * Namespace distinguishes incompatible artifacts, methods and adapters. */
18
- take(prompt: number[], ns?: string): PrefixCacheHit<State, Attachment> | null;
28
+ take(prompt: number[], ns?: string, sessionId?: string): PrefixCacheHit<State, Attachment> | null;
19
29
  /** Transfer state and its optional backing release to storage. If this
20
30
  * throws, ownership stays with the caller; successful return transfers
21
31
  * ownership even when storage immediately spills or disposes the entry. */
22
- put(tokens: number[], caches: State, ns?: string, retain?: () => void, attachments?: Attachment): void;
32
+ put(tokens: number[], caches: State, ns?: string, retain?: () => void, attachments?: Attachment, sessionId?: string): void;
23
33
  }
@@ -9,6 +9,10 @@ export interface ExecutionContext {
9
9
  /** A backend execution group exposes bounded work units and readiness. Queue
10
10
  * payloads, tensors, cache merging and sampling remain inside its adapter. */
11
11
  export interface ExecutionGroup {
12
+ /** Native preparation may borrow the execution lease between work units.
13
+ * Payloads and task failures belong to the backend's queue. */
14
+ readonly pendingTasks?: number;
15
+ advanceTask?(): Promise<void>;
12
16
  readonly active: number;
13
17
  readonly queued: number;
14
18
  readonly preparing: boolean;
@@ -30,6 +34,11 @@ export interface ExecutionGroup {
30
34
  admitNext(): boolean;
31
35
  /** Read-only budget check used to preserve admission before decode. */
32
36
  canBurst(): boolean;
37
+ /** Method-owned token demand for a compatible mixed iteration. Absent when
38
+ * the backend has no packed implementation for the current work. */
39
+ readonly mixedPreparation?: { readonly runningTokens: number; readonly minimumPreparationTokens: number };
40
+ readonly maxIterationTokens?: number;
41
+ advanceMixed?(tokenBudget: number): Promise<void>;
33
42
  advancePreparation(): Promise<void>;
34
43
  advance(): Promise<void>;
35
44
  failActive(error: unknown): void;
@@ -8,7 +8,7 @@ export function resolveExecution(
8
8
  const reasons: string[] = [];
9
9
  const sharedRequestExclusions = [
10
10
  [!capabilities.continuous, "continuous-unavailable"],
11
- [request.hasVision, "media-requires-serial"],
11
+ [request.hasVision && !capabilities.mediaBatch, "media-requires-serial"],
12
12
  [request.hasAdapters && !capabilities.adapterBatch, "adapters-require-serial"],
13
13
  [request.kvQuant && !capabilities.quantizedBatch, "kv-scheme-requires-serial"],
14
14
  [request.turboQuant && !capabilities.turboQuantBatch, "turbo-kv-requires-serial"],
@@ -18,10 +18,16 @@ export function resolveExecution(
18
18
  const sharedAdapterMethod = capabilities.sharedSpeculativeAdapters === true &&
19
19
  !sharedRequestExclusions.some(([excluded]) => excluded) &&
20
20
  capabilities.groupedMethods?.includes("speculative") === true;
21
- const speculative = capabilities.method === "autoregressive" && request.hasDraft &&
21
+ const grammarProposals = features.grammarJump === true && request.hasGrammar && !request.hasDraft && !request.hasVision &&
22
+ (!request.wantsLogprobs || capabilities.speculativeLogprobs === true) && !features.pagedKv && capabilities.method === "autoregressive" &&
23
+ capabilities.sharedGrammarProposals === true && capabilities.groupedMethods?.includes("speculative") === true &&
24
+ (!request.hasAdapters || sharedAdapterMethod) && (!request.kvQuant || capabilities.speculativeKvQuant === true) &&
25
+ (!request.turboQuant || capabilities.speculativeTurboQuant === true) &&
26
+ !sharedRequestExclusions.some(([excluded]) => excluded);
27
+ const speculative = grammarProposals || (capabilities.method === "autoregressive" && request.hasDraft &&
22
28
  !request.hasVision && (!request.hasAdapters || sharedAdapterMethod) && (!request.wantsLogprobs || capabilities.speculativeLogprobs === true) &&
23
29
  (!request.kvQuant || capabilities.speculativeKvQuant === true) &&
24
- (!request.turboQuant || capabilities.speculativeTurboQuant === true) && !features.pagedKv;
30
+ (!request.turboQuant || capabilities.speculativeTurboQuant === true) && !features.pagedKv);
25
31
  const method = speculative ? "speculative" : capabilities.method;
26
32
  const continuousExclusions = [sharedRequestExclusions[0],
27
33
  [!(capabilities.groupedMethods ?? ["autoregressive"]).includes(method), "method-requires-serial"],
@@ -32,19 +38,24 @@ export function resolveExecution(
32
38
  if (request.hasDraft && !speculative) reasons.push("draft-incompatible-with-request");
33
39
  const pagedKv = features.pagedKv && !request.hasVision && !request.hasAdapters;
34
40
  if (features.pagedKv && !pagedKv) reasons.push("paged-kv-bypassed-for-media-or-adapters");
35
- const promptCache = method !== "speculative" && !request.hasVision && !pagedKv;
36
- const fill = features.fill && method === "autoregressive" && mechanism === "serial" &&
37
- !request.hasDraft && !request.hasVision && !request.userSeed && !request.hasGrammar &&
38
- !request.wantsLogprobs && !request.kvQuant && !request.turboQuant;
41
+ const promptCache = method !== "speculative" && (!request.hasVision ||
42
+ (mechanism === "continuous" && request.hasPreparedPrefixIdentity === true && capabilities.mediaPrefixCache === true));
43
+ const sharedFill = mechanism === "continuous" && capabilities.sharedFill === true && !pagedKv;
44
+ const speculativeEcho = method === "speculative" && mechanism === "continuous" &&
45
+ capabilities.sharedSpeculativeEcho === true;
46
+ const fill = features.fill && !request.hasVision && !request.hasGrammar && !request.wantsLogprobs &&
47
+ (speculativeEcho || (method === "autoregressive" && (mechanism === "serial" || sharedFill) &&
48
+ !request.hasDraft && (!request.userSeed || sharedFill)));
49
+ // Cache-format eligibility belongs to the method's append binding.
39
50
  if (features.fill && !fill) reasons.push("fill-incompatible-with-request");
40
- const checkpoint = capabilities.checkpoints && method === "autoregressive" &&
41
- (mechanism === "serial" || capabilities.sharedCheckpoints === true) && promptCache && !request.hasGrammar &&
51
+ const checkpoint = capabilities.checkpoints && method === "autoregressive" && !request.hasVision &&
52
+ (mechanism === "serial" || capabilities.sharedCheckpoints === true) && promptCache && !pagedKv && !request.hasGrammar &&
42
53
  !features.fill && !request.wantsLogprobs;
43
54
  const compiledDecode = features.compiledDecode === true && capabilities.compiledDecode === true &&
44
- method === "autoregressive" && !request.hasAdapters && !pagedKv;
55
+ method === "autoregressive" && !request.hasAdapters && !pagedKv && !(sharedFill && fill);
45
56
  if (features.compiledDecode && !compiledDecode) reasons.push("compiled-decode-unavailable-for-request");
46
- const grammarJump = features.grammarJump === true && request.hasGrammar &&
47
- method === "autoregressive" && mechanism === "serial" && !request.wantsLogprobs;
57
+ const grammarJump = grammarProposals || (features.grammarJump === true && request.hasGrammar &&
58
+ method === "autoregressive" && mechanism === "serial" && !request.wantsLogprobs);
48
59
  if (features.grammarJump && request.hasGrammar && !grammarJump)
49
60
  reasons.push("grammar-jump-incompatible-with-request");
50
61
  return Object.freeze({ method, mechanism, pagedKv, promptCache, checkpoint, fill,
@@ -0,0 +1,44 @@
1
+ interface Task {
2
+ run(): Promise<void>;
3
+ reject(error: unknown): void;
4
+ }
5
+
6
+ /** Work whose owner requires an execution boundary, without owning a decode
7
+ * row. The driver supplies the lease; this queue owns waiting and cancellation.
8
+ * Once started, work settles before its caller can release native resources. */
9
+ export class ExecutionTasks {
10
+ #pending: Task[] = [];
11
+ get pending(): number { return this.#pending.length; }
12
+
13
+ enqueue<T>(work: () => Promise<T>, cancellation?: Cancellation): Promise<T> {
14
+ if (cancellation?.reason !== undefined) return Promise.reject(new GenerationCancelled(cancellation.reason));
15
+ return new Promise<T>((resolve, reject) => {
16
+ let unsubscribe = () => {};
17
+ const cleanup = () => unsubscribe();
18
+ const task: Task = {
19
+ async run() {
20
+ cleanup();
21
+ try { resolve(await work()); } catch (error) { reject(error); }
22
+ },
23
+ reject(error) { cleanup(); reject(error); },
24
+ };
25
+ this.#pending.push(task);
26
+ unsubscribe = cancellation?.subscribe(reason => {
27
+ const index = this.#pending.indexOf(task);
28
+ if (index < 0) return;
29
+ this.#pending.splice(index, 1);
30
+ task.reject(new GenerationCancelled(reason));
31
+ }) ?? (() => {});
32
+ if (!this.#pending.includes(task)) unsubscribe();
33
+ });
34
+ }
35
+
36
+ async advance(): Promise<void> { await this.#pending.shift()?.run(); }
37
+
38
+ rejectAll(error: unknown): void {
39
+ const tasks = this.#pending; this.#pending = [];
40
+ for (const task of tasks) task.reject(error);
41
+ }
42
+ }
43
+ import type { Cancellation } from "../contracts/generation";
44
+ import { GenerationCancelled } from "./cancellation";
@@ -0,0 +1,13 @@
1
+ import type { PrefixCache } from "../contracts/prefix-cache";
2
+
3
+ /** Compose numerical identity with a cache without exposing its storage tiers. */
4
+ export function namespacedCache<State, Attachment>(cache: PrefixCache<State, Attachment>,
5
+ namespace: (base: string) => string): PrefixCache<State, Attachment> {
6
+ return {
7
+ closeSession: id => cache.closeSession?.(id),
8
+ reclaim: () => cache.reclaim?.(),
9
+ ...(cache.prefetch ? { prefetch: (tokens: number[], ns = "", sessionId?: string) => cache.prefetch!(tokens, namespace(ns), sessionId) } : {}),
10
+ take: (tokens, ns = "", sessionId) => cache.take(tokens, namespace(ns), sessionId),
11
+ put: (tokens, state, ns = "", retain, attachments, sessionId) => cache.put(tokens, state, namespace(ns), retain, attachments, sessionId),
12
+ };
13
+ }
@@ -18,8 +18,8 @@ export async function driveExecutionGroup(
18
18
  try {
19
19
  while (!group.closed) {
20
20
  group.pruneCancelled();
21
- const held = group.admissionHeld;
22
- if (!group.active && !group.preparing && (held || !group.queued)) {
21
+ let held = group.admissionHeld;
22
+ if (!group.active && !group.preparing && (held || (!group.queued && !group.pendingTasks))) {
23
23
  release();
24
24
  await group.waitForWork();
25
25
  continue;
@@ -28,6 +28,15 @@ export async function driveExecutionGroup(
28
28
  if (!execution && group.acquireExecution) execution = await group.acquireExecution();
29
29
  if (group.closed) break;
30
30
 
31
+ // One preparation task per iteration. An exclusive model mutation
32
+ // keeps its existing drain priority; preparation cannot prolong it.
33
+ if (!held && group.pendingTasks && group.advanceTask) {
34
+ await group.advanceTask();
35
+ if (group.closed) break;
36
+ group.pruneCancelled();
37
+ held = group.admissionHeld;
38
+ }
39
+
31
40
  if (!group.preparing && !held && group.queued && group.active < group.maxActive)
32
41
  group.admitNext();
33
42
  while (group.preparing && group.canPrepareMore && !held && group.queued &&
@@ -35,6 +44,17 @@ export async function driveExecutionGroup(
35
44
  (group.preparingTokens ?? 0) + (group.nextPreparationTokens ?? 0) <= (group.maxPreparationTokens ?? Infinity)) {
36
45
  if (!group.admitNext()) break;
37
46
  }
47
+ const mixed = group.preparing && group.active ? group.mixedPreparation : undefined;
48
+ if (mixed && group.advanceMixed) {
49
+ // Reserve the method's running-token demand first; prompt work fills
50
+ // the rest. A budget smaller than one token per row still progresses.
51
+ const budget = Math.max(group.maxIterationTokens ?? Infinity,
52
+ mixed.runningTokens + mixed.minimumPreparationTokens);
53
+ await group.advanceMixed(budget);
54
+ lastYield = clock.now();
55
+ await clock.yield();
56
+ continue;
57
+ }
38
58
  if (group.preparing) {
39
59
  const activeBefore = group.active;
40
60
  await group.advancePreparation();