Skip to content

Add BitNet b1.58 ternary-weight model support - #83

Draft
justinchuby wants to merge 13 commits into
mainfrom
justinchu/bitnet
Draft

Add BitNet b1.58 ternary-weight model support#83
justinchuby wants to merge 13 commits into
mainfrom
justinchu/bitnet

Conversation

@justinchuby

Copy link
Copy Markdown
Member

Summary

Adds support for BitNet b1.58 models with ternary {-1,0,+1} weights.

Changes

  • src/mobius/models/bitnet.py: BitNetCausalLMModel with BitNetAttention (attn_sub_norm) and BitNetMLP (ffn_sub_norm)
  • Ternary weight unpacking: uint8 packed format → float {-1,0,+1} × weight_scale
  • ReLU² activation (squared ReLU)
  • Sub-layer RMSNorms after attention and FFN projections
  • Registry entry for model_type bitnet
  • Tiny test config + YAML golden test case

Architecture

BitNet b1.58 is Llama-like with these key differences:

  • BitLinear layers with ternary weights (2-bit packed)
  • ReLU² instead of SiLU
  • Extra RMSNorm after attention o_proj and FFN down_proj

Testing

All existing tests pass + 4 new BitNet build graph tests.

Note

Initial implementation uses float Linear (weights unpacked to float during preprocessing). MatMulNBits bits=2 optimization is future work pending ORT kernel support.

justinchuby and others added 11 commits April 1, 2026 17:20
New components:
- ShortConv: gated causal depthwise Conv1d (LFM2 conv layers)
  Components: in_proj -> B*x gating -> causal conv -> C gating -> out_proj

New model:
- Lfm2CausalLMModel: hybrid ShortConv + Attention with SwiGLU MLP
  Registered as "lfm2" using HybridCausalLMTask

Infrastructure:
- Lfm2Config with short_conv_kernel/short_conv_bias fields
- "conv" layer type support in hybrid cache system (_base.py)
- "lfm2" added to attn_qk_norm model list (QK norm via q/k_layernorm)
- AudioToAudioTask scaffold for future audio-to-audio models

Tests:
- 2 test configs: hybrid (conv+attn) and all-attention variants
- "conv" layer type assertion in build_graph_test.py
- All 2227 tests pass

Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
Implement LFM2-Audio (LiquidAI/LFM2-Audio-1.5B) as a 4-model ONNX package:
- audio_encoder: ConformerEncoder + adapter projection
- embedding: text + audio codebook embeddings
- decoder: LFM2 hybrid backbone with conv+attention and hybrid cache
- audio_decoder: depthformer transformer for codebook-by-codebook generation

Key changes:
- Lfm2AudioModel with 4 sub-model classes and weight routing
- Lfm2AudioConfig extending Lfm2Config with depthformer/codec fields
- AudioToAudioTask with hybrid cache support and depthformer audio_decoder
- 6 dedicated tests in TestBuildLfm2AudioGraph
- Registry entry for lfm2_audio model type

Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
- Dynamic codebook head selection via stacked_head_weights parameter
  indexed by codebook_idx using Gather+MatMul (replaces hardcoded [0])
- preprocess_weights stacks per-codebook weights into stacked tensor
- Remove unused audio_features input from embedding model (cleaner I/O)
- Fix docstring formatting for D205 lint rule

Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
- src/mobius/components/_short_conv_test.py: 18 tests covering
  ShortConv parameters, prefill (Pad-based causal conv), incremental
  decode (Concat-based rolling cache), B/C/x gating, kernel sizes
- docs/_generate_models.py: add 'Hybrid Conv+Attention' and
  'Audio-to-Audio' to _CATEGORY_DESCRIPTIONS and _CATEGORY_ORDER
- docs/model-catalog.md: add Hybrid Conv+Attention section (lfm2),
  Audio-to-Audio section (lfm2_audio), update summary table

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
- Replace duplicated _Lfm2AudioDecoderLayer and _Lfm2AudioConvLayer
  with aliases to Lfm2AttentionDecoderLayer and Lfm2ConvDecoderLayer
  from lfm2.py (Lfm2AudioConfig inherits from Lfm2Config, so the
  constructors accept it directly)
- Remove unused ShortConv import from lfm2_audio.py
- Add L4+L5 YAML test case for lfm2 (causal-lm/lfm2-1.2b.yaml)
- Add L4 YAML test case for lfm2_audio (audio/lfm2-audio-1.5b.yaml)
- Add lfm2_audio to model_coverage_test.py _COVERAGE_SKIP
  (multi-model architecture tested via TestBuildLfm2AudioGraph)

Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
Adds support for Moshi (kyutai/moshiko-pytorch-bf16) and PersonaPlex
(nvidia/personaplex-7b-v1) audio-to-audio models.

Architecture (3-model ONNX split via MoshiTask):
- embedding: text_emb + 16 per-codebook audio_emb tables
- decoder: 32-layer causal transformer (RoPE + SwiGLU MLP + RMSNorm)
- audio_decoder: 6-layer depformer depth transformer with per-codebook
  stacked gating MLPs and head_dim=depformer_dim

Key design choices:
- No audio_encoder: Moshi/PersonaPlex consume audio as codec token IDs
  directly (unlike LFM2-Audio which uses a mel-spectrogram conformer).
- MoshiTask extends AudioToAudioTask, overriding embedding (adds
  audio_codes input) and _build_audio_decoder (head_dim = depformer_dim).
- preprocess_weights handles: alpha [1,1,H] -> weight [H] reshape,
  packed in_proj_weight -> split q/k/v, linear_in -> gate/up split,
  and stacking of per-codebook depformer weights.

PersonaPlex-7b-v1 config.json is minimal (only model_type + version);
all hyperparameters are inferred from weight shapes with hardcoded v1
defaults in MoshiConfig.from_transformers.

Files:
- src/mobius/models/moshi.py: new MoshiModel with full architecture
- src/mobius/_configs.py: add MoshiConfig (depformer_dim, depformer_layers,
  depformer_num_heads, num_codebooks, audio_vocab_size)
- src/mobius/tasks/_audio_to_audio.py: add MoshiTask
- src/mobius/tasks/__init__.py: export MoshiTask, register 'moshi' task
- src/mobius/_registry.py: register personaplex + moshi, add test_model_ids
- src/mobius/models/__init__.py: export MoshiModel
- tests/build_graph_test.py: TestBuildMoshiGraph (6 tests)
- tests/synthetic_parity_test.py: skip personaplex/moshi (moshi library)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
examples/moshi_realtime.py demonstrates full-duplex real-time speech
with the Moshi ONNX pipeline:

- MoshiOnnxPipeline: loads all three ONNX models (embedding, decoder,
  audio_decoder) and manages KV cache state for streaming inference
- MoshiStreamer: full-duplex I/O using sounddevice for simultaneous mic
  recording and speaker playback in separate threads
- EnCodec-based audio tokenization (via moshi library) and detokenization
- 12.5 Hz step rate matching Moshi's 80ms frame interval
- Graceful ctrl+C handling, resource cleanup, and verbose logging
- --export-only flag to export ONNX models without running inference
- Dry-run fallback when sounddevice/moshi library is unavailable

Requirements: onnxruntime, sounddevice, numpy, moshi (kyutai/moshi)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
- tasks/_base.py: widen StatePair type to include tuple[ir.Value]
  for single-state layers (conv/lightning), matching actual 1-tuple
  returns from model forward methods

- tasks/_audio_to_audio.py: add explicit parentheses around
  'config.audio.num_mel_bins or 128' for clarity

- models/lfm2_audio.py:
  * AudioEncoder.forward: add Transpose(perm=[0,2,1]) before
    ConformerEncoder (mel is B,n_mels,T; encoder expects B,T,n_mels)
  * DepthformerDecoder.forward: build idx_expanded with runtime batch
    dim via op.Shape(projected_3d, start=0, end=1) instead of constant 1
  * DepthformerDecoder.forward: build position_ids with runtime batch
    dim via op.Shape(hidden_states, start=0, end=1) instead of [1,1]
  * preprocess_weights: fix tie_word_embeddings head_key from
    'lm_head.weight' to 'lfm.lm_head.weight' so the rename function
    maps it to 'decoder.lm_head.weight' correctly

- models/moshi.py: remove dead hidden_size/depformer_dim locals
  in preprocess_weights (assigned but never used)

- components/_short_conv_test.py: remove unused 'import onnx_ir as ir'

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
Add BitNet b1.58 causal language model with ternary {-1, 0, +1} weights.
The architecture is Llama-like with sub-layer RMSNorms (attn_sub_norm
before o_proj, ffn_sub_norm before down_proj) and squared ReLU activation.

Implementation:
- BitNetAttention: Attention subclass with attn_sub_norm
- BitNetMLP: MLP subclass with ffn_sub_norm
- BitNetCausalLMModel: CausalLMModel subclass with preprocess_weights
  that unpacks uint8-packed 2-bit ternary values and applies weight_scale
- Uses standard Linear ops (not MatMulNBits) for initial correctness;
  2-bit quantized kernels can be added as a future optimization

Also adds:
- YAML golden test case (with skip_reason pending golden JSON)
- Skill file documenting the quantized/low-bit model pattern
- Test config, registry entry, and test_model_id

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
Documents patterns for adding quantized/low-bit weight models to mobius:
- linear_class injection pattern and when to use it vs standard Linear
- Weight packing/unpacking conventions (ternary uint8 → float)
- Sub-layer norm pattern (RMSNorm before projections)
- Testing quantized models with tiny configs
- BitNet implementation as reference example

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
This reverts commit f3da0ce.

Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
@github-actions

github-actions Bot commented Apr 2, 2026

Copy link
Copy Markdown

Performance Comparison

Comparing 81240718188ca1

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0%
bert (feature-extraction) num_nodes 61 61 +0.0%
falcon model_size_bytes 364 KB 364 KB +0.0%
falcon num_nodes 66 66 +0.0%
gemma2 model_size_bytes 428 KB 428 KB +0.0%
gemma2 num_nodes 107 107 +0.0%
gpt2 model_size_bytes 388 KB 388 KB +0.0%
gpt2 num_nodes 53 53 +0.0%
llama model_size_bytes 425 KB 425 KB +0.0%
llama num_nodes 61 61 +0.0%
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0%
llama (static-cache) num_nodes 58 58 +0.0%
mamba (ssm-text-generation) model_size_bytes 360 KB 360 KB +0.0%
mamba (ssm-text-generation) num_nodes 105 105 +0.0%
phi3 model_size_bytes 421 KB 421 KB +0.0%
phi3 num_nodes 61 61 +0.0%
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0%
phi3 (static-cache) num_nodes 58 58 +0.0%
qwen2 model_size_bytes 425 KB 425 KB +0.0%
qwen2 num_nodes 61 61 +0.0%
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0%
qwen2 (static-cache) num_nodes 58 58 +0.0%
qwen3_5_moe (hybrid-text-generation) model_size_bytes 506 KB 506 KB +0.0%
qwen3_5_moe (hybrid-text-generation) num_nodes 275 275 +0.0%
qwen3_5_text (hybrid-text-generation) model_size_bytes 458 KB 458 KB +0.0%
qwen3_5_text (hybrid-text-generation) num_nodes 129 129 +0.0%
qwen3_5_vl (hybrid-qwen-vl) model_size_bytes 977 KB 977 KB +0.0%
qwen3_5_vl (hybrid-qwen-vl) num_nodes 409 409 +0.0%
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0%
t5 (seq2seq) num_nodes 174 174 +0.0%
whisper (speech-to-text) model_size_bytes 1008 KB 1008 KB +0.0%
whisper (speech-to-text) num_nodes 140 140 +0.0%

No performance regressions.

@github-actions

github-actions Bot commented Apr 2, 2026

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing 81240718188ca1

Model Sub-model Changes Status
bert (feature-extraction) model 0
falcon model 0
gemma2 model 0
gpt2 model 0
llama model 0
llama (static-cache) model 0
mamba (ssm-text-generation) model 0
phi3 model 0
phi3 (static-cache) model 0
qwen model 0
qwen (static-cache) model 0
qwen2 model 0
qwen2 (static-cache) model 0
qwen2_moe model 0
qwen2_moe (static-cache) model 0
qwen3 model 0
qwen3 (static-cache) model 0
qwen3_5_moe (hybrid-text-generation) model 0
qwen3_5_text (hybrid-text-generation) model 0
qwen3_5_vl (hybrid-qwen-vl) decoder 0
qwen3_5_vl (hybrid-qwen-vl) embedding 0
qwen3_5_vl (hybrid-qwen-vl) vision 0
qwen3_moe model 0
qwen3_moe (static-cache) model 0
qwen3_next (hybrid-text-generation) model 0
t5 (seq2seq) decoder 0
t5 (seq2seq) encoder 0
whisper (speech-to-text) decoder 0
whisper (speech-to-text) encoder 0

No architecture changes detected.


Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds new architecture support to mobius for (1) BitNet b1.58 ternary-weight CausalLMs and (2) LiquidAI/Moshi-style hybrid conv+attention and audio-to-audio model pipelines, including new task wiring, components, registry/config integration, tests, and docs updates.

Changes:

  • Add BitNetCausalLMModel with ternary weight unpacking + BitNet-specific sub-layer RMSNorms and ReLU² activation.
  • Introduce LFM2 (ShortConv + attention) and audio-to-audio task support (LFM2-Audio + Moshi/PersonaPlex), including ShortConv component + unit tests.
  • Update registry/config parsing, test coverage harness, and documentation scaffolding for new categories/model types.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/synthetic_parity_test.py Adds skip reasons for moshi/personaplex synthetic parity.
tests/model_coverage_test.py Marks lfm2_audio as covered by dedicated build-graph tests.
tests/build_graph_test.py Extends hybrid-cache assertions for conv layers; adds dedicated build tests for LFM2-Audio and Moshi splits.
tests/_test_configs.py Adds tiny BitNet config and LFM2 configs to test matrix; introduces placeholder audio-to-audio config list.
testdata/cases/causal-lm/lfm2-1.2b.yaml Adds skipped golden case for LFM2.
testdata/cases/causal-lm/bitnet-b1.58-2b.yaml Adds skipped golden case for BitNet.
testdata/cases/audio/lfm2-audio-1.5b.yaml Adds skipped golden case for LFM2-Audio.
src/mobius/tasks/_base.py Extends hybrid cache input/output support to single-state conv layers.
src/mobius/tasks/_audio_to_audio.py New multi-model task wiring for audio-to-audio pipelines + Moshi variant.
src/mobius/tasks/__init__.py Exports/registers AudioToAudioTask and MoshiTask.
src/mobius/models/moshi.py Adds Moshi/PersonaPlex 3-model split modules + preprocessing for HF weight layouts.
src/mobius/models/lfm2.py Adds LFM2 hybrid ShortConv+attention CausalLM + HF key renames.
src/mobius/models/lfm2_audio.py Adds LFM2-Audio 4-model split (audio encoder/embedding/decoder/audio decoder) + weight routing.
src/mobius/models/bitnet.py Adds BitNet model with ternary unpacking and sub-layer norms.
src/mobius/models/__init__.py Exposes new model classes.
src/mobius/components/_short_conv.py Implements ShortConv gated causal depthwise Conv1d block with cached state.
src/mobius/components/_short_conv_test.py Adds unit tests for ShortConv shapes/ops/state handling.
src/mobius/components/__init__.py Exports ShortConv from public components API.
src/mobius/_registry.py Registers bitnet, lfm2, lfm2_audio, moshi, personaplex and test model IDs/variants.
src/mobius/_configs.py Adds Lfm2Config, Lfm2AudioConfig, MoshiConfig config parsing.
examples/moshi_realtime.py Adds an example streaming pipeline for Moshi/PersonaPlex ONNX sub-models.
docs/model-catalog.md Updates catalog counts/categories and adds sections for LFM2 + audio-to-audio.
docs/_generate_models.py Adds category descriptions/order for new categories.

Comment on lines +59 to +63
# Q/K/V projections — (batch, seq_len, num_heads * head_dim)
query_states = self.q_proj(op, hidden_states)
key_states = self.k_proj(op, hidden_states)
value_states = self.v_proj(op, hidden_states)

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BitNetAttention.forward reimplements Attention.forward but omits the optional Q/K normalization path (q_norm/k_norm) when config.attn_qk_norm is enabled. This silently changes behavior vs the base Attention class for configs that use QK-norm; consider either reusing the base forward logic and inserting attn_sub_norm before o_proj, or duplicating the q_norm/k_norm handling from Attention.forward.

Copilot uses AI. Check for mistakes.
Comment on lines +149 to +177
def preprocess_weights(
self, state_dict: dict[str, torch.Tensor]
) -> dict[str, torch.Tensor]:
"""Unpack ternary weights and apply per-tensor weight_scale.

HuggingFace packs ternary {-1, 0, +1} values as 2-bit unsigned
integers, 4 values per uint8 byte. This method:
1. Collects ``*.weight_scale`` tensors (per-tensor scaling factors).
2. For each packed ``*.weight`` with a matching scale, unpacks the
uint8 → int8 ternary values and multiplies by the scale.
3. Drops the consumed ``weight_scale`` keys from the state dict.
"""
# Collect per-tensor weight scales
weight_scales: dict[str, torch.Tensor] = {}
for key, value in state_dict.items():
if key.endswith(".weight_scale"):
prefix = key[: -len(".weight_scale")]
weight_scales[prefix] = value

new_state_dict: dict[str, torch.Tensor] = {}
for key, value in state_dict.items():
if key.endswith(".weight_scale"):
continue # consumed by the matching .weight key
if key.endswith(".weight") and key[: -len(".weight")] in weight_scales:
scale = weight_scales[key[: -len(".weight")]]
if value.dtype == torch.uint8:
value = _unpack_ternary_weights(value)
value = value.float() * scale.float()
new_state_dict[key] = value

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ternary weight unpacking in preprocess_weights is untested. Since this logic is model-specific and easy to regress (bit packing order, shape expansion, scale application), add a focused unit test that validates _unpack_ternary_weights and the weight_scale multiplication against a small hand-constructed packed tensor.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +133 to +152
def __init__(self, config: Lfm2AudioConfig):
super().__init__()
self.text_embed = Embedding(config.vocab_size, config.hidden_size, config.pad_token_id)
# Audio codebook embedding: codebooks * audio_vocab_size entries
audio_vocab = config.audio_vocab_size * config.num_codebooks
self.audio_embed = Embedding(audio_vocab, config.hidden_size)

def forward(
self,
op: builder.OpBuilder,
input_ids: ir.Value,
):
"""Forward: text_ids -> inputs_embeds.

Returns text embeddings only. Audio codebook embeddings are
handled separately by the audio_decoder's depth_embeddings.
Runtime assembles text + audio at the sequence level.
"""
return self.text_embed(op, input_ids)

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_Lfm2AudioEmbedding defines self.audio_embed but forward() never uses it, so its weights will never be realized in the exported ONNX graph and will show up as "not applied" during weight application. Either wire audio_embed into the embedding computation (if the model needs discrete audio token embeddings) or remove it (and the corresponding weight rename) to avoid dead parameters and confusing logs.

Copilot uses AI. Check for mistakes.
Comment on lines +331 to +345
# Per-codebook logit projection
# Each codebook has its own embedding + tied head
self.depth_embeddings = nn.ModuleList([])
for _ in range(config.num_codebooks):
self.depth_embeddings.append(
Linear(depthformer_dim, config.audio_vocab_size, bias=False)
)

# Stacked head weights for dynamic codebook selection via Gather.
# Shape: (num_codebooks, audio_vocab_size, depthformer_dim)
# In preprocess_weights, this is assembled from per-codebook weights.
self.stacked_head_weights = nn.Parameter(
[config.num_codebooks, config.audio_vocab_size, depthformer_dim]
)

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_Lfm2AudioDecoderModule keeps a per-codebook depth_embeddings ModuleList, but forward() selects weights exclusively from stacked_head_weights. This leaves depth_embeddings.* weights unused in the ONNX graph (and likely unmapped at apply time). Consider deleting the per-codebook weights from the preprocessed state_dict after stacking, or removing depth_embeddings entirely if it’s only present for HF name compatibility, to reduce log noise and memory footprint.

Copilot uses AI. Check for mistakes.
Comment thread docs/model-catalog.md
Comment on lines +3 to 5
**mobius** supports 275 registered model types across 12 categories.
This catalog lists every supported architecture with its module class, task type,
and example HuggingFace model IDs.

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs/model-catalog.md is updated to reflect new totals/categories, but it still doesn’t mention newly added model types from this PR (e.g., bitnet, moshi, personaplex) anywhere in the catalog tables. If this page is meant to list every supported model_type, add rows for these entries (or regenerate the page from the registry to keep it consistent).

Copilot uses AI. Check for mistakes.
justinchuby and others added 2 commits April 1, 2026 18:04
- Fix __all__ sort order in models/__init__.py (BitNetCausalLMModel now
  alphabetical)
- Add input validation in _unpack_ternary_weights (ndim check)
- Add bitnet_test.py with 9 unit tests for _unpack_ternary_weights:
  shape, ternary value set, known byte round-trip, edge cases (all-zero,
  all-one, all-two bytes), dtype, invalid input, multi-column

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
Address Copilot reviewer comment: BitNetAttention.forward was missing
the optional Q/K normalization path (q_norm/k_norm) that the base
Attention class supports. Added the full q_norm/k_norm handling
(both full and per-head modes) between Q/K/V projections and RoPE,
matching the base Attention.forward logic.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com>
@justinchuby justinchuby added the ai Created by an AI agent label Apr 2, 2026
@justinchuby justinchuby self-assigned this Apr 2, 2026
@justinchuby
justinchuby marked this pull request as draft April 2, 2026 15:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai Created by an AI agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants