feat(gemma4): Gemma 4 12B unified (gemma4_unified) — encoder-free text + vision + audio - #74
Conversation
…ghts Add `is_unified` and `use_bidirectional_attention` to Gemma4Config, parsed in parse_config from the unified checkpoint's top-level model_type / architectures and text_config. The unified 12B checkpoint shares the dense gemma4 text decoder, so detection only gates load-time behavior: - Leave vision_config None for unified checkpoints so the SigLIP Gemma4VisionConfig parser does not mis-read the unified vision_config and wrongly enable a vision tower (text-only load). - Add vision_embedder. to the vision-skip prefix set so the unified checkpoint's image embedder weights drop cleanly instead of erroring on an unexpected weight. Other families are byte-identical (prefix-only addition). tie_word_embeddings=true checkpoints (no lm_head.weight) already derive a tied head from embed_tokens. Both .d.cts artifacts hand-synced. Rust tests cover unified detection via model_type and via architecture, and that plain gemma4 stays is_unified=false. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Normalize gemma4_unified model_type onto the shared gemma4 registry key
(alongside the existing gemma4_text normalization) so loadSession on the
unified 12B checkpoint drives the gemma4 decoder. No new registry row.
Tests:
- detectModelType resolves gemma4_unified -> gemma4 (and keeps gemma4_text /
plain gemma4 working).
- Presence-gated e2e: loadSession the 12B checkpoint and assert greedy decode
is coherent ("Paris"; ocean sentence) with a degenerate-repetition guard.
- Add isUnified to the existing Gemma4Config TS stub (now a required field).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ction detectModelType only normalized on config.model_type, so a unified checkpoint that carries architectures: ["Gemma4UnifiedForConditionalGeneration"] but no top-level model_type defaulted to qwen3 and misrouted to Qwen3Model.load(). The native loader (gemma4/persistence.rs parse_config) already flags is_unified on EITHER model_type == "gemma4_unified" OR that architecture; mirror the architecture check in TS so arch-only unified checkpoints resolve to the shared gemma4 loader. Add a regression test for the model_type-absent case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the building blocks for the gemma4_unified (12B) encoder-free vision path: - UnifiedVisionConfig parsed from the unified vision_config sub-dict (model_patch_size 48, mm_embed_dim/output_proj_dims 3840, mm_posemb_size 1120, num_soft_tokens 280), plumbed onto Gemma4Config as unified_vision_config (disjoint from the SigLIP vision_config). - Gemma4UnifiedVisionEmbedder: patch_ln1 -> patch_dense -> patch_ln2 -> add 2D positional embedding -> pos_norm, mirroring mlx-vlm's VisionEmbedder. - Gemma4ImageProcessor unified path: patchify at model_patch_size=48 into [n, 6912] flattened patches + [n, 2] xy position ids (num_soft_tokens=n, no pooling division); ProcessedGemma4Image carries Option<position_ids>. Both .d.cts artifacts hand-synced. Unit tests cover embedder forward shape + position-mask wiring and the config-parse field population. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Activate the encoder-free vision path for gemma4_unified end-to-end:
- parse_config populates unified_vision_config when is_unified.
- Gemma4Inner builds the unified vision_embedder + embed_vision + unified
image processor (vision_tower stays None); has_vision now true for unified.
- sanitize_weights keeps vision_embedder./embed_vision. when either vision
path is active (drops them only when both configs are absent).
- persistence loads vision_embedder.* (patch LayerNorms + dense + pos table +
pos_norm) and the shared embed_vision.embedding_projection (factored into a
helper reused by both the SigLIP and unified paths).
- build_gemma4_vision_embeds branches on the unified embedder: forward patches
+ position ids, add the batch dim, then the existing scaled-text masked
scatter with the mask_count==feature_count assertion.
Presence-gated vision e2e (model + ocr.png) asserts a coherent, non-degenerate
image description. Validated on the real 24GB checkpoint: the model reads the
document image ('financial document ... bank statement', 'BAN REC').
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The unified encoder-free vision loader installed each tensor only when present in the checkpoint, so a missing key silently kept the constructor default (pos_embedding mx.zeros, LayerNorms/Linear constructor-init). A truncated shard or bad conversion could load "successfully" with random or zero vision weights, producing coherent-looking but image-wrong output that the smoke test would not catch. validate_required_weights now rejects a load that declares a unified vision config but is missing any required vision_embedder weight/bias, the pos_embedding tensor, or the embed_vision.embedding_projection weight, erroring with the missing key name. This runs on the same load path that already validates the text weights, before any weight application, matching the text loader's fail-closed behavior. The SigLIP vision_tower path and non-unified loads are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port mlx-vlm gemma4/language.py _block_sequence_ids_for_mask + _apply_blockwise_bidirectional_overlay as boolean keep-masks (true=keep), matching create_sliding_mask / create_causal_mask BF16-safe semantics. Each contiguous image run attends to itself bidirectionally; text stays causal. Unit-tested: group numbering, single/two-block bidirectionality, no cross-block attend, length-mismatch no-op, type-id builder. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fill
Wire the blockwise bidirectional attention overlay into the unified-vision
paged prefill (run_paged_vlm_prefill -> run_paged_vlm_prefill_layer_loop).
The gate (is_unified + use_bidirectional_attention=="vision" + image tokens
present + seq_len>1; audio is Phase 3 and never enters the expanded stream)
materializes explicit boolean keep-masks for BOTH layer types only when
active, leaving the None/causal fast path byte-identical otherwise:
- global/full: explicit causal mask threaded through Gemma4Attention
forward_paged's fresh-prefill branch (new explicit_prefill_mask arg).
- sliding: causal+window mask, overlaid.
Force a single pass-1 chunk when the overlay is active so the image block is
never split across chunks (default chunk size is already single-shot; this
guards MLX_PAGED_PREFILL_CHUNK_SIZE). Pass-2 (final token, seq_len==1) and
decode never apply the overlay.
ocr.png greedy T=0 now reads 'Trunch Parish Council' / 'BANK RECONCILIATION
AS AT 31' (vs Phase 2a causal 'BAN REC').
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bidirectional vision overlay mask only reaches GlobalPaged/Sliding layers. KV-shared layers (SharedOnGlobal/SharedOnSliding) run forward_paged_shared, which takes no mask and would silently stay causal — a half-applied overlay across the stack. The 12B unified checkpoint has num_kv_shared_layers==0 so this never fires today; convert the latent silent-corruption into a loud error if a shared unified checkpoint is ever loaded. Also drop a duplicate #[allow(clippy::too_many_arguments)] on forward_paged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ghts Parse the unified checkpoint's audio ids and frame size (audio_token_id, boa_token_id, eoa_token_id from eoa_token_index, audio_samples_per_token) and a has_audio flag keyed on the audio_config sub-dict. Non-unified gemma4 leaves all of these inert. sanitize_weights now keeps model.embed_audio.* for a checkpoint that declares an audio_config (splitting the old unconditional embed_audio drop from the always-dropped audio encoder prefixes); non-unified loads stay byte-identical. apply_audio_weights loads embed_audio.embedding_projection (dense bf16, affine fallback), mirroring the vision projection, and validate_required_weights fails closed on a missing audio projection weight when has_audio. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… overlay gate Build embed_audio in Gemma4Inner::new (RMSNormNoScale + Linear, 640->hidden, reusing Gemma4MultimodalEmbedder) when the checkpoint declares an audio_config. Add the encoder-free audio_processor: frames_from_pcm pads a mono f32 PCM clip to a multiple of 640 and reshapes to [n_frames, 640] with no scaling (one frame = one 40 ms audio token), and expand_audio_tokens turns each audio placeholder into boa + audio_token x n_frames + eoa. build_gemma4_audio_embeds projects the raw windows through embed_audio and masked_scatters them into the (sqrt(hidden)-scaled) text stream at every audio token — causal, no bidirectional overlay, audio features unscaled. Mirrors build_gemma4_vision_embeds; vision stays byte-identical. Extend the unified-vision overlay gate via a pure vision_overlay_active helper: the bidirectional overlay is disabled whenever audio tokens are present in the expanded stream (mixed image+audio is causal, audio wins). Image-only behavior is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… audio Two correctness fixes for the Phase 3a audio pipeline: 1. has_audio was keyed on `audio_config` key PRESENCE, which serde reports as true even for `audio_config: null` and for E2B's legacy mel `audio_config` dict. That regressed every non-unified gemma4 load: 26B/31B (audio_config: null) failed the embed_audio validator, and E2B tried to keep its [1536,1536] mel projection under the raw-window 640->hidden embedder. Gate has_audio on `is_unified && audio_config non-null`; gate the audio ids on has_audio too. Regression test covers the E2B (legacy dict) and 26B/31B (null) shapes. 2. build_gemma4_audio_embeds passed zero-frame audio (mask_count==feature_count ==0) into masked_scatter, which divides by an empty source (indices.remainder(0)). Short-circuit feature_count==0 to return the scaled text embeds unchanged. Added a zero-frame expansion test. Both found by adversarial review; verified against the real e2b/26b/31b configs and the masked_scatter source-size handling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Anti-aliased polyphase resample (24kHz→16kHz) of mlx-vlm's ask_voice.wav. The unified audio feature extractor requires mono float32 16kHz; this is the presence-gated fixture the Phase 3b audio e2e loads (mirrors examples/ocr.png). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add decode_wav_to_pcm: a dependency-free RIFF/WAVE parser supporting PCM16 and IEEE float32, multi-channel→mono downmix, with a 16 kHz-only guard (resampling deferred). Mirrors the encoded-bytes-in image path so the public audio turn can decode an audio clip Rust-side. 6 unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…odal turn Public audio surface mirroring images: ChatMessage.audio, audio params on chat_session_continue / chat_stream_session_continue (guard-only, reject with the typed restart prefix on text-only continue), and the gemma4 image-guard now also gates audio on a has_audio load flag. Engine: extract_audio_from_messages + WholeTurnArgs.audio; the multimodal dispatch fires on images OR audio; ChatBackend::supports_audio() (default false; gemma4 = embed_audio.is_some()) rejects audio on non-audio families pre-render. Gemma4 turn: generalize the vision paged cores into a multimodal turn — prepare_multimodal_tokens expands <|image|> then <|audio|> placeholders and decodes+frames the audio; build_gemma4_multimodal_embeds chains the image (@258880) and audio (@258881) masked_scatters into the same scaled text stream (image-only stays byte-identical; audio-only skips the image scatter). cached_audio_key parallels cached_image_key (audio-held sessions reject text deltas; audio change cold-restarts). serialize_message_for_jinja emits a {type:"audio"} content part (image-first, then audio) → the template's <|audio|> placeholder. Deleted the now-subsumed build_gemma4_vision_embeds / build_gemma4_audio_embeds. Tests: 6 WAV-decode units already landed; +3 multimodal serializer units (audio-only, mixed image+audio ordering). Both index.d.cts synced. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
stream.ts forwards the new `audio` positional into chatStreamSessionContinue. ChatSession gains SendOptions.audio, a lastAudioKey + computeAudioKey (shared FNV-1a framing with images), audioChanged folded into the cold-restart decision in send()/sendStream(), audio threaded through runStartPath/runStartStreamPath + buildUserMessage, computeTrailingAudioKey hydration on cold replay, and lastAudioKey reset on reset()/rollback. Delta continues pass null audio. SessionCapableModel continue signatures carry the audio arg. Test mocks + arg-index assertions updated for the new arity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Presence-gated e2e mirroring the vision e2e: drives ChatSession.sendStream
with { audio } against the real 24GB unified checkpoint and asserts coherent,
non-degenerate output (no exact-string match). A second case drives a combined
image+audio turn to prove both scatters build, the prompt carries both token
runs, and the turn runs causally (overlay off when audio present).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3b added an `audio` positional argument between `images` and `config` on the shared chat surface — the `chat_napi_surface!` macro families, the `SessionCapableModel` TS interface, `makeStreamingModel`, and `ChatSession.send`'s delta path all expect it. QianfanOCR uses HANDWRITTEN NAPI continue methods (not the macro), which still had the pre-audio signature, so driving Qianfan through `ChatSession` / `makeStreamingModel` shifted its positional args: the caller's `config` landed in the audio slot and was silently dropped (or the NAPI dispatch errored). Add an `audio` parameter (between `images` and `config`) to Qianfan's `chat_session_continue` and `chat_stream_session_continue`, matching the macro families' exact `ts_args_type` wording. Qianfan has no audio support, so a non-empty `audio` is rejected at the boundary with the shared no-audio message (IMAGE_CHANGE_RESTART_PREFIX-prefixed, mirroring `engine::session`); `None` / empty is a complete no-op and audio is never threaded into Qianfan's command processing — existing text + image behaviour stays byte-identical. QianfanOCR is the only handwritten (non-macro) chat family driven through `makeStreamingModel`; Harrier is embeddings-only with no chat surface. Sync both `.d.cts` artifacts (build-regenerated `packages/core` and hand-maintained `crates/mlx-core`). Add Rust unit tests for the boundary guard, a TS regression test asserting the Qianfan-shaped delta path delivers `(userMessage, images, audio, config)` without dropping config, and a gemma4 contract test locking that a text delta after an audio turn is rejected exactly like one after an image turn. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… guard) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0b4c51c90c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
After an image/audio turn, gemma4's native session refuses a text-only delta and rejects with a message starting `IMAGE_CHANGE_REQUIRES_SESSION_RESTART:`. Previously that raw error propagated to the caller, making any media turn effectively single-shot. Both `send()` and `sendStream()` now catch that exact prefix on the delta path and transparently replay the full conversation (including the earlier media turn already stored in history) through the existing cold-start path (`runStartPath` / `runStartStreamPath`), passing the trailing media keys so `lastImagesKey`/`lastAudioKey` stay consistent across the replay. The streaming rejection surfaces as a THROW on the generator's first iteration: the native worker-thread guard fires before any prefill via `sink.send(Err(e))`, and the stream.ts bridge re-throws it before any chunk is yielded. The delta-continue handler wraps the for-await in a nested try/catch, delegates to the replay stream only when nothing was emitted yet (`!sawFinal && accumulated === ''`), and guards the commit `finally` with a `delegated` flag so the replay path owns the single history commit. This is the TS-only correctness floor; warm KV reuse is a later phase. Family-agnostic and additive: the catch fires only on the typed prefix (today only gemma4 raises it); text-only sessions, qwen3.5/lfm2/qianfan, the happy delta path, and media-change restarts are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…age (Phase 2) A text follow-up after a pure-causal media turn (AUDIO, or a NON-UNIFIED gemma4 image) now warm-continues on the live media KV instead of cold-restarting. The vision-core finalize keeps the global paged KV registered for content-addressed reuse and arms a new internal marker `media_session_continuable`, so the native chatSessionContinue succeeds; the delta restores the prefix via replay over the live global KV — the same mechanism a text warm-continue uses. UNIFIED bidirectional-vision image turns stay single-shot (Phase 3). Key reconciliation (R1): the sliding-history checkpoint is structurally a no-op on KV-shared checkpoints (SharedOnSliding layers store no flat K/V), exactly as for a text warm-continue — so continuation does NOT depend on it; it rides the replay path. On a "length" finish the final token is forwarded once (mirroring the text path's materialize_final) so the kept- live global KV content-addresses against the saved history. Proven byte-exact by a new warm==cold golden e2e on a non-unified image turn (identical rawText + numTokens). Audio shares the identical finalize code; its byte-exact golden is ill-posed via the public API (the chat template strips prior-turn reasoning and there is no raw-token-prefill entrypoint), so the audio test asserts warm-continuation + final-answer parity. The text_delta_image_guard pin is flipped to a contract: media held + continuable → ALLOW; not continuable → still REJECT. Gates: clippy --all-targets -D warnings clean (pre-existing block v0.1.6 note only); cargo fmt --check clean; cargo test -p mlx-core --lib gemma4 141 passed; yarn typecheck clean; vp lint 0 errors; e2e golden 3 passed; existing gemma4 + pure-text regressions green. No NAPI surface change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…estore + hoist marker reset Two ship-blockers in the Phase-2 warm media->text continuation, found by codex adversarial review and confirmed by control-flow tracing. FIX 1 (correctness): finalize_vision_turn_media_state now arms media_session_continuable ONLY when the sliding-history checkpoint actually stored. On KV-shared checkpoints (e2b, num_kv_shared_layers=20) the shared-on-sliding layers hold no flat K/V, so stored=false and a warm restore would rebuild media-position sliding K/V from raw <|image|>/<|audio|> token embeddings instead of the scattered SigLIP/audio features -- numerically unfaithful. When not stored, release the kept-live request and downgrade to a clean non-continuable state so the follow-up delta cold-restarts. Non-KV-shared checkpoints (12B audio, num_kv_shared_layers=0) store real feature K/V -> stay warm + faithful (state="live"). FIX 2 (robustness): hoist the media_session_continuable=false reset to immediately before the side-effecting prepare_turn_with_max_cache_hit_tokens call in both vision cores (sync + stream). The prepare releases the prior kept-live request and can fail via `?`; resetting first means any prepare failure cold-restarts safely instead of leaving a stale `true`. Test rework: the e2b non-unified image e2e block now asserts cold-restart coherence (it is KV-shared -> no longer warm-continues); the 12B audio block keeps its warm cachedTokens>0 + final-answer-parity assertions (canonical faithful warm path). Doc-comments updated accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rt cold-restart in e2e Hardens the warm media->text continuation gate against two issues found by an adversarial review: - A keep-live with zero full blocks (a media turn shorter than block_size) returns Ok without registering the request, so is_live_for_continue() is false while keep_live_ok is true. Arming the marker there would let the next delta fall to a fresh paged text prefill that re-embeds the media placeholder ids as text (unfaithful). Now arm only when stored AND is_live_for_continue(). Unreachable on shipped configs (media turns far exceed the 16-token block), but cheap defense-in-depth. - The e2b (KV-shared) and unified image e2e blocks only asserted coherence, so a regression that wrongly re-armed the marker could pass on a coherent-but-wrong warm answer. Assert cachedTokens===0 (the vision cold prefill primes max_cache_hit_tokens=0, so a correct cold-restart reports 0; a wrong warm continuation reports >0) to fail loud on accidental warm reuse. 12B audio stays warm+faithful (media history >> block_size -> full blocks -> is_live_for_continue true); e2b/unified cold-restart with cachedTokens===0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-chunk turns The streaming history commit used `buildAssistantMessage(finalRaw ?? accumulated, ...)`. gemma4 is the only family whose native streaming done-chunk emits `text: ""` (visible text already streamed as per-token deltas), so `finalRaw === ""` and `"" ?? accumulated` yields `""` — an empty assistant turn was stored, degrading any cold-restart that resends `this.history` (e.g. the gemma4 media→text restart). Add a non-reasoning-only `accumulatedVisible` accumulator at each of the 4 streaming methods and change the commit to `finalRaw || accumulatedVisible`. `||` returns `finalRaw` unchanged for the other 4 families (non-empty done text — byte-identical) and falls through to the reconstructed marker-free visible text only for gemma4's empty-`finalRaw` case, with reasoning-body deltas excluded so thinking models do not leak prior reasoning into history. `accumulated` is kept for the sendStream catch guard. Adds 3 mock-level tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Relax the gemma4 media-continuation eligibility gate so a unified
bidirectional-vision image turn (12B, num_kv_shared_layers=0) warm-continues a
follow-up text delta instead of cold-restarting.
The warm text delta routes through the generic paged causal text path
(run_paged_prefill_chunk → run_paged_prefill_layer_loop) with a plain offset
causal mask — it never runs the bidirectional vision overlay (the overlay only
makes the IMAGE span bidirectional during prefill, a no-op over text queries in
both the warm and cold paths). So it is numerically faithful; eligibility no
longer needs the !overlay_active / !is_unified exclusions.
Rename gemma4_no_overlay_continuable → gemma4_media_continuable (now just
has_audio || has_image) and its finalize parameter / call-site locals. The real
safety net is unchanged: the stored && live_for_continue gate in
finalize_vision_turn_media_state keeps the 12B (non-KV-shared) warm while a
KV-shared checkpoint (e2b) stores nothing and cleanly cold-restarts. The
KV-shared-unified overlay hard-error in run_paged_vlm_prefill is untouched.
e2e (12B + e2b checkpoints, ocr.png / audio fixtures): e2b non-unified image
still cold-restarts (cachedTokens===0, negative control); 12B audio warm stays
byte-exact ("English"=="English"); 12B unified image warm-continues
(cachedTokens>0). Strict warm==cold byte parity does NOT hold for the unified
image — a deterministic ~1-ULP BF16 cache-hit-kernel reduction-order drift (the
documented paged_decode_long_context_1ulp class) flips one early near-tie argmax
("...the main subject of the image" then " provided" vs ".") that cascades into
a different-but-coherent tail, both analyzing the same image ("Trunch Parish
Council"). The block falls back to cachedTokens>0 + coherence, documented in a
code comment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…h the stored-gate code The function header carried superseded pre-fix prose that contradicted its own code body: it claimed the next delta "REPLAYS (re-walks) the matched prefix (state=\"replay\")", that "continuation does NOT depend on the checkpoint storing", and called the stored==false downgrade an "earlier wrong draft". The shipped code does the opposite — it arms the marker ONLY when `stored && live_for_continue` (model.rs:2036), and the path that actually warm-continues (12B, num_kv_shared_layers=0) resolves the sliding caches to state="live" (run_sliding_only_prefill skipped, no media position re-embedded) with the global prefix reused in place (cachedTokens>0, only the suffix forwarded). Rewrite the header to describe that, and the R1 tail's "replay restore" -> "live restore". Documentation only; no code/behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The subagent-driven-development scratch ledger leaked one file (.superpowers/sdd/mc-phase2-report.md) into commit aaae2f9. Add /.superpowers/ to .gitignore and untrack it so the branch tree carries only source. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ache gemma4 image/audio turns publish their K/V blocks into the model's shared cross-session content-addressed prefix cache via finalize_turn_keep_live_per_block, but registered them with an empty media-position slice. The per-block hash then depended only on placeholder token ids, so two requests with the same surrounding prompt and the same number of <|image|>/<|audio|> placeholders but different media content hashed identically — a later content-address lookup from another session could hit stale media-feature K/V. Fold the per-image/per-audio content keys into the per-block extra_keys at the sole publishing site. A new gemma4_media_token_positions helper builds the (token_pos, content_key) pairs from the expanded prompt and the image/audio token ids, scanning placeholder positions and pairing each with new_image_key/new_audio_key. The two vision cold-prefill prepares (skip_lookup=true) and the consumer text-path lookup keep an empty slice: the prefills do not register into the shared cache, and the consumer lookup now simply misses the content-keyed media blocks, which is the safe outcome. Behavior-neutral: only the cache keys of registered media blocks change, never the current turn's forward/decode (live block_table). The three media-continuation e2e blocks stay byte-identical (warm continues read the live block_table; the e2b cold-restart uses a skip_lookup prefill — neither does a content-hash read). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…prefix cache" This reverts commit ef379e1.
A gemma4 media (image/audio) turn arms `media_session_continuable` and keeps its paged KV live so a following text delta can warm-continue. But on the model's SHARED cross-session paged adapter another session can run `reset_for_new_request` and release the live request after the marker is armed. `text_delta_image_guard` previously ALLOWED the delta on the marker ALONE; with no live request the text path then runs a content-address prefix lookup (`find_cached_prefix`, skip_lookup=false) over `[media-prefix + delta]`, which can hit stale media-feature K/V from another session, or unfaithfully re-prefill the media placeholders. Require BOTH the marker AND `paged_adapter.is_live_for_continue()` to allow the warm continue. When the live request is gone, fall through to the existing `IMAGE_CHANGE_RESTART_PREFIX` rejection so the TS floor cold-restarts (resend full history → faithful vision/audio prefill, skip_lookup=true). The warm continue itself reads the live `block_table` (no lookup), and a vision prefill uses skip_lookup=true, so after this change no skip_lookup=false content lookup ever walks media-placeholder blocks — closing the cross-session stale-KV consumer at its only site and fixing the unfaithful-re-prefill edge. This replaces the reverted content-keying attempt (ef379e1): media blocks are also re-registered under token-only hashes by the generic text terminal `finalize_paged_turn`, so keying only the vision finalize did not isolate them; closing the consumer makes the registered media-block keys irrelevant. Single-session sequential use keeps `is_live_for_continue()==true` at the delta, so the guard still returns None and warm-continues exactly as before. Updates the guard-matrix unit test: a continuable delta on a not-live (None) adapter now correctly REJECTS (the cross-session-released hazard); the marker-true AND live → warm-continue path is covered by the 12B e2e (a live adapter needs real Metal allocation, not unit-constructible). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lders The shared paged adapter's continue-turn-failure fallback re-runs a content-address prefix lookup using the caller's skip_lookup flag. The gemma4 text path passed false, so an alloc-pressure failure inside continue_turn could content-HIT media blocks registered token-only by another ChatSession (per-block hashes cover token ids, not media feature K/V) and reuse that session's stale media K/V. Gate skip_lookup on whether the prompt tokens still carry an image or audio placeholder id. Media-placeholder prompts then never trigger a content-address lookup in any fallback branch, so the cross-session stale-media-KV consumer is gone. The happy path reuses the live block_table and never consults skip_lookup, so warm continuation stays byte-identical; only the rare continue_turn-failure branch changes, now re-prefilling the placeholders as text instead of doing the leaky lookup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ry points
The Phase-1 cold-restart floor caught the typed media-held native
rejection (IMAGE_CHANGE_REQUIRES_SESSION_RESTART:) only on send() and
sendStream(), transparently replaying the full conversation through the
cold start path so a text follow-up after a gemma4 image/audio turn never
hard-errors. The tool-result twins sendToolResult()/sendToolResultStream()
hit the same native guard but had no catch, so a tool result rejected on
held media surfaced as a hard error (HTTP 500 / SSE error in the server).
Extract a shared restart core that takes a pre-built pending ChatMessage
(runStartPathWithMessage / runStartStreamPathWithMessage) and make
runStartPath / runStartStreamPath thin wrappers that build the user message
and delegate, so the send/sendStream call sites stay byte-identical. Add the
media-held catch to both tool-result methods: the restart pushes the pending
{ role: 'tool', content, toolCallId, isError } message and cold-replays the
full history (which holds the prior media turn) with mediaChanged=true /
isFirstTurn=false, preserving isError and the media bytes. Streaming mirrors
sendStream's delegated flag and gates the finally commit on
(sawFinal && !delegated) so the restart core is not double-committed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e session `sendToolResult`/`sendToolResultStream` always dispatched a delta continue (`chatSessionContinueTool`/`chatStreamSessionContinueTool`) first, with no `turnCount===0` start-path routing. After an interrupted media-held replay, the start-path rollback wipes the native KV cache and resets `turnCount` to 0 while leaving the unresolved tool-call flag set. The caller's natural retry then dispatched a delta against the wiped cache, which the native side rejects with an un-prefixed "requires an initialized session" error that does not match the media-held catch — a hard error on every retry. Add a `turnCount===0` precheck to both tool-result entry points that routes the pending tool message through the cold start path, mirroring how `send`/`sendStream` self-heal via their `isFirstTurn` routing. The precheck and the existing media-held catch now share a per-twin `replayToolResultThroughStart(Stream)Path` helper. The repair is idempotent: a re-interrupted cold replay rolls back to `turnCount=0` again, so the next retry re-routes. A normal tool result always has `turnCount>=1`, so the happy path is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 79f13d7d57
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…start-path restart The start path always re-renders the full preserved history, so the post-restart sticky media keys are the trailing media keys of that history, not the single-turn literal args. A restart driven by a change in only one modality (e.g. an audio-only turn after an earlier image turn) passed a null images key for the untouched modality, nulling lastImagesKey even though image A was still live in the native cache. hasImages then lied, and a later send with that same image A was mis-detected as a change, triggering a spurious extra restart that duplicated image A in the prompt. Both start-path success branches now set lastImagesKey/lastAudioKey from computeTrailingImagesKey()/computeTrailingAudioKey(), matching the existing startFromHistory*/tool-result-replay precedent. The now-unused newImagesKey/newAudioKey params are dropped from the two *WithMessage cores and their wrappers, with all call sites updated. Adds a regression test asserting hasImages stays true across an audio-change restart and that a subsequent same-image turn stays on the delta path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The template-less manual prompt formatter emits no <|audio|> placeholder, so a unified/audio checkpoint without a chat template failed every audio turn: expand_audio_tokens treated zero placeholders with supplied clips as a hard error, which prepare_audio_tokens propagated before prefill. Mirror the image path (expand_image_tokens): when no placeholder is present but clips are supplied, insert each clip's boa + audio_token x n_frames + eoa span after BOS, in clip order. The Err is now reserved for a genuine placeholder/ clip-count mismatch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 53f21c3648
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The macOS Build job produced a non-deterministically miscompiled native addon under CARGO_INCREMENTAL=1 + the restored rust-cache: it aborted at napi module registration (mlx_core::_napi_rs_internal_register_init) on dlopen in every test worker, even though the generated register glue is byte-identical to the passing base commit (0b4c51c) and a clean local build of the same HEAD loads fine. Switch all jobs to CARGO_INCREMENTAL=0 (deterministic non-incremental release codegen) and bump the rust-cache prefix-key v1-rust -> v2-rust to orphan the poisoned archives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two defensive no-op-in-the-common-case hardening edits in gemma4 model.rs: - PLE prefill mask: exclude both image AND audio token positions from the per-layer-embedding residual, not just image positions. Audio positions hold projected audio features (not learned-token embeddings), same as image positions. Gated on config.audio_token_id; inert today because every audio/unified checkpoint ships hidden_size_per_layer_input=0 so self.ple is None and the masking block is skipped. - text_delta_image_guard: reject a continuable media session by the media_session_continuable marker when the paged request is no longer live, not solely by the cached media key. A warm text delta clears cached_image_key but leaves the marker armed, so the key-only fallback could silently warm-continue against a released request; gate the cold-restart on the true media-held signal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 10d3fe04d7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…t parity) `save_paged_history` cleared `cached_image_key` but not `cached_audio_key` on a fresh text-only paged save, while the flat `save_cache_state` clears both. After an audio turn on a reused model, a text-only paged start (no `reset()`) therefore left `cached_audio_key` stale, and the next text delta's `text_delta_image_guard` wrongly forced an "audio state" media restart on a text-only session. Mirror the flat path: clear `cached_audio_key` alongside `cached_image_key` in both branches of `save_paged_history`. On a warm reuse the key is already `None` (the delta guard keeps it so), making the clear a no-op; on a fresh start it drops the stale key. This drops no cold-restart signal: the audio-change decision is computed in TS (`computeAudioKey(opts.audio) !== this.lastAudioKey`), never from the native `cached_audio_key`, which is set fresh each media turn and consumed only by the guard's reject messages. Add a regression test driving a fresh text-only `save_paged_history` after a simulated audio turn; it asserts the key clears and the guard no longer forces an audio restart, and fails on the pre-fix code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 40b5bf9. Configure here.
…prefix `verify_cache_prefix` forced a flat prefix-reuse miss when a non-continuable session still held a cached image key, but not when it held a cached audio key. Extend the existing guard to OR in `cached_audio_key` so a non-continuable session holding stale audio KV also misses (resetting the media KV instead of reusing it as a token-id prefix hit), and update the comment to "image or audio". Continuable (warm-continue) media sessions stay excluded by the `&& !media_session_continuable` term, and clean text-only sessions (both keys None) are unaffected. This is symmetry hardening: the guard is currently unreachable for media-capable gemma4 (a paged-adapter gemma4 returns early from `paged_turn` before the flat path's `verify_cache_prefix`, and `cached_audio_key` is only set on the paged finalize), but it matches the existing image guard and future-proofs a refactor that routes a media-capable gemma4 through the flat path. Adds a unit test that drives the audio guard directly over an otherwise-hitting prefix; it fails on the pre-fix image-only guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: befca77360
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…back prepare_multimodal_tokens ran image expansion before audio expansion. On the manual no-placeholder fallback (tokenizer without a chat template, emitting neither <|image|> nor <|audio|>), each modality's span is inserted right after BOS, so the expansion that runs LAST lands first. Running image first then audio therefore produced BOS -> audio -> image -> text, reversing the serializer's canonical image-then-audio order. Run audio expansion FIRST and image expansion LAST so the image span lands first after BOS, restoring BOS -> image -> audio -> text. Features are unaffected (the merge scatters by token id); this only fixes the modality sequence the model sees (attention/RoPE positions). The chat-template path is order-independent: each expansion replaces only its own placeholder id in place. Add a pure-Vec<u32> regression test pinning image-runs-last => image-first. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The native addon aborted intermittently in CI's test job — every vitest worker hit `panic in a function that cannot unwind` at `mlx_core::_napi_rs_internal_register_init` on `dlopen`, failing the run. Root cause (verified by loading the CI-built artifacts locally + lldb): the addon binary and the `mlx.metallib` were BOTH valid — the cycle-4 metallib is byte-identical to a local one that loads fine, and the CI log shows the metallib downloaded successfully before the abort. The real cause is `#[napi(module_exports)] init()`, which eagerly forced `GENERATION_STREAM` (a `LazyLock<Stream>`) at module load — i.e. it ran `Stream::new(Gpu)`, doing MLX/Metal GPU work during `dlopen`, inside a non-unwinding `extern "C"` boundary. When that Metal init fails (here ~70 vitest workers each creating a GPU stream at the same instant on the shared CI runner), the panic cannot unwind across the boundary and the process `abort`s. The created stream was never read anywhere, so the eager init had no purpose. Fix: remove the `module_exports` init hook and the unused `GENERATION_STREAM` static. MLX brings up its default device/stream lazily on the first real op, so generation is unaffected; module load now does no GPU work and cannot abort at `dlopen`. Confirmed: the rebuilt addon no longer emits a `_napi_rs_internal_register_init` symbol at all. Also reverts 10d3fe0 (CARGO_INCREMENTAL=0 + rust-cache prefix-key v2): that was based on an incorrect "incremental miscompile" diagnosis — the artifacts were never miscompiled — and had no effect on the real failure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ea9c562f86
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…n_embedder bf16 (#76) ## What Enable `mlx convert` to quantize **`gemma4_unified`** (Gemma 4 12B — encoder-free text+vision+audio) checkpoints. The runtime loader already supports `gemma4_unified` (PR #74); only the **converter** didn't recognize it, and the default quant predicate would have corrupted one vision weight. Motivating model: `google/gemma-4-12B-it-qat-q4_0-unquantized` (dense 12B, bf16, QAT-trained for llama.cpp `q4_0`). The best quality+speed target is **4-bit affine group_size 32** — near-bf16 quality at 4-bit memory/speed. ``` mlx convert <ckpt> -o <out> --quantize --q-mode affine --q-bits 4 --q-group-size 32 ``` ## Changes 1. **Recognize `gemma4_unified`** — `Gemma4Recipe::model_types` / `recipe_for` / `CONVERTIBLE_MODEL_TYPES` route it to the existing shared `Gemma4Recipe` (it is dense — no MoE split, sym8-supported, no MTP — so it behaves identically to `gemma4` on every `recipe_for`-keyed path). 2. **Keep `vision_embedder.*` bf16** — `should_quantize()` excludes any key containing `vision_embedder`. The encoder-free unified vision patch projection is installed **dense-bf16-only** by the loader (`apply_unified_vision_embedder_weights`, no `.scales` branch), so quantizing `patch_dense.weight` would corrupt the vision path. `embed_vision`/`embed_audio` projections **still quantize** (they have affine loader branches). `vision_embedder` is unified-only → zero collateral on other families. 3. **Sanitize keeps `vision_embedder.*` bare** — sibling of `embed_vision.*`, not mis-prefixed under `language_model.model.`. 4. **CLI passes `gemma4_unified` through unchanged** — *not* collapsed to `'gemma4'`. The driver's `model_type` is the CLI value, and the gemma-QAT (wNa8o8) prequantized importer gate is exact `Some("gemma4")`; collapsing would (a) dead-code the native `recipe_for("gemma4_unified")` arm and (b) misroute a gemma-QAT unified checkpoint into the E2B-only importer. ## End-to-end validation Converted the real 23GB checkpoint → **8.4GB** `{bits:4, group_size:32, mode:affine}`: - auto-detected `gemma4_unified` (passthrough confirmed) - `vision_embedder.*` → **bf16, no `.scales`** ✓ · text / `embed_vision` / `embed_audio` → quantized (U32 + scales) ✓ - model **loads and generates coherent text** (e.g. *"The capital of France is Paris."*) ## Tests - Rust: `should_quantize` excludes `vision_embedder` (with positive controls that `embed_vision` + text layers still quantize); `gemma4_unified` resolves to a recipe + is in `CONVERTIBLE_MODEL_TYPES`; sanitize keeps `vision_embedder.*` bare; extended `recipe_registry_reproduces_inline_flags` (sym8 allowlist). - TS: `convert-cmd.test.ts` drives `run()` and asserts the `modelType` handed to native is `gemma4_unified` (and `gemma4`/`gemma4_text` still map to `gemma4`). - Gates: `convert::` 106 pass, fmt + clippy clean, CLI test 4/4, typecheck clean. ## Review Three `/codex:adversarial-review` cycles → final verdict **approve, no material findings**. The CLI-collapse misroute (cycle #1) was fixed; a flagged sanitize-test SIGABRT (cycle #2) was confirmed to be a review-harness/metallib environment artifact (the pre-existing `gemma4_recipe_sanitize_transforms` test aborts identically; CI runs one process per binary). 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes conversion routing and quantization rules for a new model type (wrong passthrough could misroute QAT checkpoints); CI runner change is low risk but affects all macOS jobs. > > **Overview** > Adds first-class **`gemma4_unified`** support in `mlx convert` so encoder-free Gemma 4 unified checkpoints can be quantized without breaking vision or misrouting QAT imports. > > The native converter registers **`gemma4_unified`** on the shared **`Gemma4Recipe`**, excludes **`vision_embedder.*`** from quantization (bf16-only loader path), and keeps those weights at **bare keys** during sanitize. The CLI **auto-detects** `gemma4_unified` (including architecture-only configs) and **passes the string through** instead of collapsing to `gemma4`, avoiding the E2B prequantized importer gate. > > CI moves macOS jobs to **`macos-26`** and runs ignored cargo model e2e tests with **`--test-threads=1`** to avoid Metal GPU watchdog timeouts on shared runners. Docs and TS/Rust tests cover detection, quant exclusions, and sanitize routing. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fb4e8e0. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

What
Adds support for
gemma4_unified— Google's Gemma 4 12B encoder-free multimodal model (Gemma4UnifiedForConditionalGeneration). Previously./.cache/models/gemma-4-12b-itfailed to load:detectModelTypeonly knewgemma4/gemma4_text, sogemma4_unifiedthrewUnsupported model_type.Delivered in 3 independently-shippable phases, extending the existing
gemma4Rust module (shared text decoder + projection) behind anis_unifiedflag — no new model family.How it works
Vision (encoder-free):
VisionEmbedder=patch_ln1 → patch_dense(6912→3840) → patch_ln2 → +2D pos_embedding → pos_norm(one matmul, no SigLIP). Image preproc resizes to 48-multiples, reshapes into[n_patches, 6912]patches with a meshgridposition_ids, rescales ÷255 (no normalize). Merge scatters image features at token258880. A blockwise bidirectional attention overlay lets image tokens attend each other on both global+sliding prefill masks (gemma4 masks are boolean keep-masks → overlay =base | same_block), single-chunk prefill, guarded against KV-shared layers.Audio (encoder-free, raw-window — NOT mel): raw 16 kHz mono PCM → pad to ×640 →
embed_audio=RMSNormNoScale(640) + Linear(640→3840)(1 token = 640 samples = 40 ms). WAV bytes decoded by a dependency-free hand-rolled RIFF/WAVE parser (PCM16 + IEEE float32, downmix, 16 kHz required). Tokens expand toBOA(256000) + audio(258881)×n + EOA(258883); features scatter at258881unscaled (text is ×√3840). Audio is causal and its presence disables the vision overlay. Public surface mirrors images field-for-field (ChatMessage.audio→ NAPI →ChatSession.send(text, { audio })).Test plan
-D warnings/ fmtocr.png("BANK RECONCILIATION…") · audio coherent · mixed image+audio causalNon-unified gemma4 (e2b/26b/31b) + all other families (qwen3/qwen3.5/lfm2/qianfan) load + chat byte-identically — audio is purely additive (active only for unified +
has_audio).Review
Per-phase
/codex:adversarial-review(each finding verified by control-flow subagent) + a final whole-branch pass:Deferred (intentional, noted)
Audio resampling (16 kHz-only enforced) · exact mlx-vlm token-parity (semantic parity is the bar; CatmullRom vs PIL-bicubic resize + bf16) · quantized
mlx convertforgemma4_unified· ChatSession catch-restart-replay UX (pre-existing for images, would change that contract).🤖 Generated with Claude Code
Note
High Risk
Touches chat session routing, NAPI ABI for all families, and core Gemma 4 attention/KV paths for multimodal prefill; regressions could mis-route config, drop history on restarts, or break non-unified models if guards are wrong.
Overview
Adds
gemma4_unified(Gemma 4 12B encoder-free multimodal) on the existinggemma4Rust path viais_unified/has_audio, routinggemma4_unified/gemma4_textcheckpoints through one registry key and loading unified vision/audio embedder weights instead of dropping them.Multimodal inference: encoder-free vision (patch embedder, scatter at image tokens, optional bidirectional prefill mask on global paged layers) and audio (16 kHz mono WAV decode, 640-sample frames,
embed_audio, BOA/audio/EOA token expansion, causal merge; audio disables the vision overlay). Fresh turns with images/audio go throughvision_turn; continue/delta stays text-only and rejects non-empty image/audio with the typedIMAGE_CHANGE_REQUIRES_SESSION_RESTART:prefix.Public API:
ChatMessage.audio,SendOptions.audio, and a newaudiopositional argument onchatSessionContinue/ streaming continue (betweenimagesandconfig) across NAPI typings and families (including Qianfan ABI alignment).ChatSessionupdates cover media-key restarts, gemma4 streaming history when the final chunk is empty, and transparent cold replay when native code rejects a text-only delta while media KV is held (sync, stream, tool paths).Stability: removes the NAPI module-load hook that eagerly created a GPU stream (avoids Metal init
abortunder concurrent test workers).Tests: large
chat-sessionregression suite, Gemma 4 unified detection/load/e2e (text, vision, audio, media→text continuation), and stub signature fixes;.gitignoreadds/.superpowers/.Reviewed by Cursor Bugbot for commit ea9c562. Bugbot is set up for automated code reviews on this repo. Configure here.