diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 07951ef81d..7d5f3fbf40 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -948,5 +948,5 @@ jobs:
run: gh release edit "desktop-v${VERSION}" --draft=false
- name: Upload latest.json to rolling release last
- if: ${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }}
+ if: ${{ !contains(needs.setup.outputs.version, '-') }}
run: gh release upload buzz-desktop-latest latest.json --clobber
diff --git a/.gitignore b/.gitignore
index 65ddcaf1c4..f26e74136c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,10 @@
/dist/
/admin-web/dist/
+# Python cache
+__pycache__/
+*.pyc
+
# lefthook-generated hook scripts (machine-specific)
.hooks/
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 5c8e263a2a..5be50d6e2d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -712,6 +712,37 @@ Defines `parse_relay_message`, `OkResponse`, `RelayMessage` directly in `src/lib
---
+### Desktop channel apps
+
+Buzz Desktop can install an MCP App as a channel tab. The Tauri host reads the
+app resource and sanitizes its Content Security Policy (CSP) before the user
+reviews the requested domains and browser permissions. Buzz stores that
+approved policy with the channel installation.
+
+Buzz reads the resource again when the user opens the tab. The current policy
+must be a subset of the approved policy. The trusted outer frame then places the
+app HTML in an opaque-origin iframe with `allow-scripts`. The app can call
+MCP tools that are visible to app callers through the host bridge. An app can
+request a channel post, but Buzz publishes the exact attributed message only
+after user approval.
+
+For each MCP tool call, Buzz can add host-owned binding context under
+`params._meta["xyz.block.buzz/context"]`. The current context contains available
+community, channel, and app-installation references. Buzz removes any
+caller-supplied value under the `xyz.block.buzz/*` host namespace before it adds
+the current host value. These references are routing context, not authorization.
+This contract does not currently define thread, project, or external Space
+references.
+
+See the [MCP App channel-host trust-boundary diagram](docs/architecture/mcp-app-channel-host.mmd).
+
+This host is disabled on Windows until the WebView2 subframe IPC boundary has
+been verified. The desktop UI does not advertise channel Apps on that platform,
+the Rust connection boundary rejects them, and the untrusted App protocol is
+not registered.
+
+---
+
## 7. Security Model
Every security-sensitive operation uses an explicit, verified pattern. No implicit trust.
diff --git a/Cargo.lock b/Cargo.lock
index ea5b02aaab..9a3f91671d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -52,6 +52,7 @@ dependencies = [
"cfg-if 1.0.4",
"getrandom 0.3.4",
"once_cell",
+ "serde",
"version_check",
"zerocopy",
]
@@ -144,7 +145,7 @@ checksum = "5d0a66767aaf7d483c556386fb68ca2fba9347684d8bb17a4bd8b755851870f7"
dependencies = [
"arrayvec",
"aws-lc-rs",
- "base64",
+ "base64 0.22.1",
"byteorder",
"minicbor",
"rustls-pki-types",
@@ -409,13 +410,23 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+[[package]]
+name = "atomic-write-file"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84790c55b5704b0d35130bf16a4ce22a8e70eb0ea773522557524d9a4852663d"
+dependencies = [
+ "nix 0.30.1",
+ "rand 0.9.4",
+]
+
[[package]]
name = "attohttpc"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9"
dependencies = [
- "base64",
+ "base64 0.22.1",
"http",
"log",
"rustls",
@@ -486,7 +497,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"axum-macros",
- "base64",
+ "base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@@ -562,6 +573,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -580,6 +597,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
+[[package]]
+name = "beef"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
+
[[package]]
name = "bip39"
version = "2.2.2"
@@ -778,7 +801,7 @@ name = "buzz-acp"
version = "0.1.0"
dependencies = [
"anyhow",
- "base64",
+ "base64 0.22.1",
"buzz-core",
"buzz-persona",
"buzz-sdk",
@@ -838,7 +861,7 @@ dependencies = [
"arc-swap",
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"getrandom 0.4.3",
"hex",
"nix 0.31.3",
@@ -897,7 +920,7 @@ name = "buzz-cli"
version = "0.1.0"
dependencies = [
"axum",
- "base64",
+ "base64 0.22.1",
"buzz-core",
"buzz-persona",
"buzz-sdk",
@@ -938,7 +961,7 @@ dependencies = [
name = "buzz-core"
version = "0.1.0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"chrono",
"hex",
"hmac 0.13.0",
@@ -980,7 +1003,7 @@ dependencies = [
name = "buzz-dev-mcp"
version = "0.1.0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"buzz-cli",
"buzz-core",
"git-credential-nostr",
@@ -1107,7 +1130,7 @@ dependencies = [
"appattest",
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"byteorder",
"chrono",
"getrandom 0.4.3",
@@ -1142,7 +1165,7 @@ dependencies = [
"async-compression",
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"buzz-audit",
"buzz-auth",
"buzz-conformance",
@@ -1252,7 +1275,7 @@ name = "buzz-test-client"
version = "0.1.0"
dependencies = [
"anyhow",
- "base64",
+ "base64 0.22.1",
"buzz-core",
"buzz-media",
"buzz-sdk",
@@ -1278,6 +1301,25 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "buzz-voice"
+version = "0.1.0"
+dependencies = [
+ "atomic-write-file",
+ "hex",
+ "ort",
+ "ort-sys",
+ "rand 0.10.1",
+ "sentencepiece-model",
+ "serde",
+ "serde_json",
+ "sha2 0.11.0",
+ "sherpa-onnx",
+ "symphonia",
+ "tempfile",
+ "tokenizers",
+]
+
[[package]]
name = "buzz-workflow"
version = "0.1.0"
@@ -1345,6 +1387,26 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+[[package]]
+name = "bzip2"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
+dependencies = [
+ "bzip2-sys",
+ "libc",
+]
+
+[[package]]
+name = "bzip2-sys"
+version = "0.1.13+1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
[[package]]
name = "castaway"
version = "0.2.4"
@@ -1593,6 +1655,7 @@ dependencies = [
"itoa",
"rustversion",
"ryu",
+ "serde",
"static_assertions",
]
@@ -2153,6 +2216,15 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "dary_heap"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "dashmap"
version = "6.2.1"
@@ -2642,6 +2714,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "esaxx-rs"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6"
+
[[package]]
name = "etcetera"
version = "0.11.0"
@@ -2699,6 +2777,12 @@ dependencies = [
"smallvec",
]
+[[package]]
+name = "extended"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
+
[[package]]
name = "fancy-regex"
version = "0.11.0"
@@ -2709,6 +2793,17 @@ dependencies = [
"regex",
]
+[[package]]
+name = "fancy-regex"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
+dependencies = [
+ "bit-set 0.8.0",
+ "regex-automata",
+ "regex-syntax",
+]
+
[[package]]
name = "fast-srgb8"
version = "1.0.0"
@@ -3022,8 +3117,8 @@ dependencies = [
"libc",
"log",
"rustversion",
- "windows-link 0.1.3",
- "windows-result 0.3.4",
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
]
[[package]]
@@ -3100,7 +3195,7 @@ dependencies = [
name = "git-credential-nostr"
version = "0.1.0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"nostr",
"serde_json",
"zeroize",
@@ -3110,7 +3205,7 @@ dependencies = [
name = "git-sign-nostr"
version = "0.1.0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"chrono",
"hex",
"libc",
@@ -3285,7 +3380,7 @@ version = "1.0.0-rc.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bon",
"bytes",
"futures",
@@ -3591,7 +3686,7 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
@@ -3622,7 +3717,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core 0.61.2",
+ "windows-core 0.62.2",
]
[[package]]
@@ -4375,6 +4470,39 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+[[package]]
+name = "logos"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458"
+dependencies = [
+ "logos-derive",
+]
+
+[[package]]
+name = "logos-codegen"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f"
+dependencies = [
+ "beef",
+ "fnv",
+ "lazy_static",
+ "proc-macro2",
+ "quote",
+ "regex-syntax",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "logos-derive"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d"
+dependencies = [
+ "logos-codegen",
+]
+
[[package]]
name = "loom"
version = "0.7.2"
@@ -4434,6 +4562,22 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "macro_rules_attribute"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c"
+dependencies = [
+ "macro_rules_attribute-proc_macro",
+ "pastey",
+]
+
+[[package]]
+name = "macro_rules_attribute-proc_macro"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c"
+
[[package]]
name = "matchers"
version = "0.2.0"
@@ -4449,6 +4593,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+[[package]]
+name = "matrixmultiply"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
+dependencies = [
+ "autocfg",
+ "rawpointer",
+]
+
[[package]]
name = "maybe-async"
version = "0.2.11"
@@ -4545,7 +4699,7 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05
dependencies = [
"anyhow",
"async-trait",
- "base64",
+ "base64 0.22.1",
"bytes",
"crypto_box",
"ed25519-dalek",
@@ -4558,7 +4712,7 @@ dependencies = [
"mesh-llm-types",
"model-artifact",
"nostr-sdk",
- "prost",
+ "prost 0.14.3",
"rand 0.10.1",
"rustls",
"serde",
@@ -4647,7 +4801,7 @@ dependencies = [
"argon2",
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"bytes",
"chacha20poly1305",
"chrono",
@@ -4696,7 +4850,7 @@ dependencies = [
"opentelemetry 0.31.0",
"opentelemetry-otlp 0.31.1",
"opentelemetry_sdk 0.31.0",
- "prost",
+ "prost 0.14.3",
"rand 0.10.1",
"regex-lite",
"reqwest 0.12.28",
@@ -4737,7 +4891,7 @@ version = "0.74.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"argon2",
- "base64",
+ "base64 0.22.1",
"chacha20poly1305",
"chrono",
"crypto_box",
@@ -4785,8 +4939,8 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05
dependencies = [
"anyhow",
"async-trait",
- "prost",
- "prost-build",
+ "prost 0.14.3",
+ "prost-build 0.14.3",
"protoc-bin-vendored",
"rmcp",
"schemars",
@@ -4822,7 +4976,7 @@ dependencies = [
"anyhow",
"hex",
"iroh",
- "prost",
+ "prost 0.14.3",
"serde_json",
"sha2 0.10.9",
]
@@ -4953,7 +5107,7 @@ version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108"
dependencies = [
- "base64",
+ "base64 0.22.1",
"evmap",
"http-body-util",
"hyper",
@@ -4991,6 +5145,28 @@ dependencies = [
"sketches-ddsketch",
]
+[[package]]
+name = "miette"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
+dependencies = [
+ "cfg-if 1.0.4",
+ "miette-derive",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "miette-derive"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "mime"
version = "0.3.17"
@@ -5136,6 +5312,28 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "monostate"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67"
+dependencies = [
+ "monostate-impl",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "monostate-impl"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "more-asserts"
version = "0.3.1"
@@ -5242,6 +5440,21 @@ dependencies = [
"tempfile",
]
+[[package]]
+name = "ndarray"
+version = "0.17.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
+dependencies = [
+ "matrixmultiply",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "portable-atomic",
+ "portable-atomic-util",
+ "rawpointer",
+]
+
[[package]]
name = "ndk-context"
version = "0.1.1"
@@ -5388,6 +5601,18 @@ dependencies = [
"memoffset",
]
+[[package]]
+name = "nix"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
+dependencies = [
+ "bitflags 2.13.0",
+ "cfg-if 1.0.4",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "nix"
version = "0.31.3"
@@ -5478,7 +5703,7 @@ version = "0.44.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e826dd648489de2c5b293920e20b92932ef820302007c1987c758d4d06eeb2cf"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bech32",
"bip39",
"bitcoin_hashes",
@@ -5949,7 +6174,7 @@ dependencies = [
"opentelemetry-http",
"opentelemetry-proto 0.31.0",
"opentelemetry_sdk 0.31.0",
- "prost",
+ "prost 0.14.3",
"reqwest 0.12.28",
"thiserror 2.0.18",
]
@@ -5964,7 +6189,7 @@ dependencies = [
"opentelemetry 0.32.0",
"opentelemetry-proto 0.32.0",
"opentelemetry_sdk 0.32.1",
- "prost",
+ "prost 0.14.3",
"thiserror 2.0.18",
"tokio",
"tonic",
@@ -5977,11 +6202,11 @@ version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
dependencies = [
- "base64",
+ "base64 0.22.1",
"const-hex",
"opentelemetry 0.31.0",
"opentelemetry_sdk 0.31.0",
- "prost",
+ "prost 0.14.3",
"serde",
"serde_json",
"tonic",
@@ -5996,7 +6221,7 @@ checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638"
dependencies = [
"opentelemetry 0.32.0",
"opentelemetry_sdk 0.32.1",
- "prost",
+ "prost 0.14.3",
"tonic",
"tonic-prost",
]
@@ -6078,6 +6303,24 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "ort"
+version = "2.0.0-rc.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
+dependencies = [
+ "ndarray",
+ "ort-sys",
+ "smallvec",
+ "tracing",
+]
+
+[[package]]
+name = "ort-sys"
+version = "2.0.0-rc.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
+
[[package]]
name = "os_str_bytes"
version = "6.6.1"
@@ -6259,6 +6502,16 @@ dependencies = [
"pest",
]
+[[package]]
+name = "petgraph"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
+dependencies = [
+ "fixedbitset 0.5.7",
+ "indexmap",
+]
+
[[package]]
name = "petgraph"
version = "0.8.3"
@@ -6391,7 +6644,7 @@ version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
- "base64",
+ "base64 0.22.1",
"indexmap",
"quick-xml 0.39.4",
"serde",
@@ -6457,13 +6710,22 @@ dependencies = [
"serde",
]
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
[[package]]
name = "portmapper"
version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"derive_more",
"hyper-util",
@@ -6633,6 +6895,16 @@ dependencies = [
"unarray",
]
+[[package]]
+name = "prost"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
+dependencies = [
+ "bytes",
+ "prost-derive 0.13.5",
+]
+
[[package]]
name = "prost"
version = "0.14.3"
@@ -6640,7 +6912,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes",
- "prost-derive",
+ "prost-derive 0.14.3",
+]
+
+[[package]]
+name = "prost-build"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
+dependencies = [
+ "heck",
+ "itertools",
+ "log",
+ "multimap",
+ "once_cell",
+ "petgraph 0.7.1",
+ "prettyplease",
+ "prost 0.13.5",
+ "prost-types 0.13.5",
+ "regex",
+ "syn 2.0.117",
+ "tempfile",
]
[[package]]
@@ -6653,15 +6945,28 @@ dependencies = [
"itertools",
"log",
"multimap",
- "petgraph",
+ "petgraph 0.8.3",
"prettyplease",
- "prost",
- "prost-types",
+ "prost 0.14.3",
+ "prost-types 0.14.3",
"regex",
"syn 2.0.117",
"tempfile",
]
+[[package]]
+name = "prost-derive"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
+dependencies = [
+ "anyhow",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "prost-derive"
version = "0.14.3"
@@ -6675,13 +6980,35 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "prost-reflect"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2"
+dependencies = [
+ "logos",
+ "miette",
+ "once_cell",
+ "prost 0.13.5",
+ "prost-types 0.13.5",
+]
+
+[[package]]
+name = "prost-types"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
+dependencies = [
+ "prost 0.13.5",
+]
+
[[package]]
name = "prost-types"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
dependencies = [
- "prost",
+ "prost 0.14.3",
]
[[package]]
@@ -6748,6 +7075,33 @@ version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
+[[package]]
+name = "protox"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a"
+dependencies = [
+ "bytes",
+ "miette",
+ "prost 0.13.5",
+ "prost-reflect",
+ "prost-types 0.13.5",
+ "protox-parse",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "protox-parse"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3a462d115462c080ae000c29a47f0b3985737e5d3a995fcdbcaa5c782068dde"
+dependencies = [
+ "logos",
+ "miette",
+ "prost-types 0.13.5",
+ "thiserror 1.0.69",
+]
+
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
@@ -7053,7 +7407,7 @@ dependencies = [
"thiserror 2.0.18",
"unicode-segmentation",
"unicode-truncate",
- "unicode-width",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -7116,7 +7470,7 @@ dependencies = [
"strum",
"time",
"unicode-segmentation",
- "unicode-width",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -7128,6 +7482,43 @@ dependencies = [
"bitflags 2.13.0",
]
+[[package]]
+name = "rawpointer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
+
+[[package]]
+name = "rayon"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-cond"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f"
+dependencies = [
+ "either",
+ "itertools",
+ "rayon",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
[[package]]
name = "redb"
version = "3.1.3"
@@ -7249,7 +7640,7 @@ version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"encoding_rs",
"futures-channel",
@@ -7297,7 +7688,7 @@ version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
@@ -7387,7 +7778,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59"
dependencies = [
"async-trait",
- "base64",
+ "base64 0.22.1",
"bytes",
"chrono",
"futures",
@@ -7455,7 +7846,7 @@ dependencies = [
"async-trait",
"aws-creds",
"aws-region",
- "base64",
+ "base64 0.22.1",
"bytes",
"cfg-if 1.0.4",
"futures-util",
@@ -7856,6 +8247,18 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
+[[package]]
+name = "sentencepiece-model"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40b87bf750a8322c3236d7aa63c1f4a6862187d00d2d8b038e1dfe263bfe43ec"
+dependencies = [
+ "miette",
+ "prost 0.13.5",
+ "prost-build 0.13.5",
+ "protox",
+]
+
[[package]]
name = "serde"
version = "1.0.228"
@@ -8066,6 +8469,28 @@ dependencies = [
"os_str_bytes",
]
+[[package]]
+name = "sherpa-onnx"
+version = "1.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b142d3f255cb4e4b7808ea25869db6f5714e0a3550da355234483b4db552055"
+dependencies = [
+ "serde",
+ "serde_json",
+ "sherpa-onnx-sys",
+]
+
+[[package]]
+name = "sherpa-onnx-sys"
+version = "1.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffc951af03dc0653c0622158ca8a585a6f2bc43b7b06048cf0e5b5020005c227"
+dependencies = [
+ "bzip2",
+ "tar",
+ "ureq",
+]
+
[[package]]
name = "shlex"
version = "1.3.0"
@@ -8201,8 +8626,8 @@ name = "skippy-protocol"
version = "0.74.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
- "prost",
- "prost-build",
+ "prost 0.14.3",
+ "prost-build 0.14.3",
"protoc-bin-vendored",
"serde",
]
@@ -8230,7 +8655,7 @@ dependencies = [
"anyhow",
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"blake3",
"clap",
"futures-util",
@@ -8337,6 +8762,18 @@ dependencies = [
"der",
]
+[[package]]
+name = "spm_precompiled"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326"
+dependencies = [
+ "base64 0.13.1",
+ "nom",
+ "serde",
+ "unicode-segmentation",
+]
+
[[package]]
name = "sprig"
version = "0.1.0"
@@ -8365,7 +8802,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"cfg-if 1.0.4",
"chrono",
@@ -8471,7 +8908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
dependencies = [
"atoi",
- "base64",
+ "base64 0.22.1",
"bitflags 2.13.0",
"byteorder",
"chrono",
@@ -8612,6 +9049,164 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
+[[package]]
+name = "symphonia"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
+dependencies = [
+ "lazy_static",
+ "symphonia-bundle-flac",
+ "symphonia-bundle-mp3",
+ "symphonia-codec-aac",
+ "symphonia-codec-alac",
+ "symphonia-codec-pcm",
+ "symphonia-codec-vorbis",
+ "symphonia-core",
+ "symphonia-format-isomp4",
+ "symphonia-format-ogg",
+ "symphonia-format-riff",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-bundle-flac"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
+dependencies = [
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-bundle-mp3"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
+dependencies = [
+ "lazy_static",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-codec-aac"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
+dependencies = [
+ "lazy_static",
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-codec-alac"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
+dependencies = [
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-codec-pcm"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
+dependencies = [
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-codec-vorbis"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
+dependencies = [
+ "log",
+ "symphonia-core",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-core"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
+dependencies = [
+ "arrayvec",
+ "bitflags 1.3.2",
+ "bytemuck",
+ "lazy_static",
+ "log",
+]
+
+[[package]]
+name = "symphonia-format-isomp4"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
+dependencies = [
+ "encoding_rs",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-format-ogg"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
+dependencies = [
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-format-riff"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
+dependencies = [
+ "extended",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-metadata"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
+dependencies = [
+ "encoding_rs",
+ "lazy_static",
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-utils-xiph"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
+dependencies = [
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
[[package]]
name = "syn"
version = "1.0.109"
@@ -8709,7 +9304,7 @@ version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432"
dependencies = [
- "unicode-width",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -8783,9 +9378,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
dependencies = [
"anyhow",
- "base64",
+ "base64 0.22.1",
"bitflags 2.13.0",
- "fancy-regex",
+ "fancy-regex 0.11.0",
"filedescriptor",
"finl_unicode",
"fixedbitset 0.4.2",
@@ -8935,6 +9530,39 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+[[package]]
+name = "tokenizers"
+version = "0.22.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223"
+dependencies = [
+ "ahash",
+ "aho-corasick",
+ "compact_str 0.9.1",
+ "dary_heap",
+ "derive_builder",
+ "esaxx-rs",
+ "fancy-regex 0.14.0",
+ "getrandom 0.3.4",
+ "itertools",
+ "log",
+ "macro_rules_attribute",
+ "monostate",
+ "paste",
+ "rand 0.9.4",
+ "rayon",
+ "rayon-cond",
+ "regex",
+ "regex-syntax",
+ "serde",
+ "serde_json",
+ "spm_precompiled",
+ "thiserror 2.0.18",
+ "unicode-normalization-alignments",
+ "unicode-segmentation",
+ "unicode_categories",
+]
+
[[package]]
name = "tokio"
version = "1.52.3"
@@ -9070,7 +9698,7 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"futures-core",
"futures-sink",
@@ -9171,7 +9799,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
dependencies = [
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"bytes",
"h2",
"http",
@@ -9200,7 +9828,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
dependencies = [
"bytes",
- "prost",
+ "prost 0.14.3",
"tonic",
]
@@ -9210,8 +9838,8 @@ version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8"
dependencies = [
- "prost",
- "prost-types",
+ "prost 0.14.3",
+ "prost-types 0.14.3",
"tonic",
]
@@ -9500,6 +10128,15 @@ dependencies = [
"tinyvec",
]
+[[package]]
+name = "unicode-normalization-alignments"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de"
+dependencies = [
+ "smallvec",
+]
+
[[package]]
name = "unicode-properties"
version = "0.1.4"
@@ -9520,9 +10157,15 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools",
"unicode-segmentation",
- "unicode-width",
+ "unicode-width 0.2.2",
]
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -9535,6 +10178,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+[[package]]
+name = "unicode_categories"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+
[[package]]
name = "universal-hash"
version = "0.5.1"
@@ -9557,6 +10206,22 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+[[package]]
+name = "ureq"
+version = "2.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
+dependencies = [
+ "base64 0.22.1",
+ "flate2",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-pki-types",
+ "url",
+ "webpki-roots 0.26.11",
+]
+
[[package]]
name = "url"
version = "2.5.8"
@@ -10480,8 +11145,8 @@ dependencies = [
"log",
"serde",
"thiserror 2.0.18",
- "windows 0.61.3",
- "windows-core 0.61.2",
+ "windows 0.62.2",
+ "windows-core 0.62.2",
]
[[package]]
@@ -10548,7 +11213,7 @@ checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8"
dependencies = [
"anyhow",
"async-trait",
- "base64",
+ "base64 0.22.1",
"bytes",
"clap",
"crc32fast",
@@ -10585,7 +11250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee"
dependencies = [
"async-trait",
- "base64",
+ "base64 0.22.1",
"blake3",
"bytemuck",
"bytes",
diff --git a/Cargo.toml b/Cargo.toml
index 3ac7ee4cce..3268cfaf8d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -26,6 +26,7 @@ members = [
"crates/buzz-pair-relay",
"crates/buzz-relay-mesh",
"crates/buzz-dev-mcp",
+ "crates/buzz-voice",
"examples/countdown-bot",
]
exclude = ["desktop/src-tauri"]
diff --git a/Justfile b/Justfile
index 2d76f1a7b9..64a1f36daf 100644
--- a/Justfile
+++ b/Justfile
@@ -276,6 +276,7 @@ test-unit:
#!/usr/bin/env bash
if command -v cargo-nextest &>/dev/null; then
cargo nextest run -p buzz-core -p buzz-auth --lib
+ cargo nextest run -p buzz-voice --lib
cargo nextest run -p buzz-cli
# buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra).
# They guard the embedded-migrator invariant (exactly the consolidated
diff --git a/README.md b/README.md
index 72af92ce13..2c58ceecad 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@
Forge ·
Agents ·
Architecture ·
+ Releasing ·
Apache 2.0
diff --git a/RELEASING.md b/RELEASING.md
index 45f0f8638f..11f669fc9b 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`:
| Lane | Entry point | Artifact |
|------|-------------|----------|
-| Desktop | `Prepare Desktop Release` / `just release-desktop` | Signed desktop app (macOS/Linux) |
+| Desktop | `Prepare Desktop Release` | Packaged desktop app (signed/notarized macOS, unsigned Windows, and Linux) |
| Relay | `just release-relay` | `ghcr.io/block/buzz` container image |
| Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity |
@@ -16,13 +16,22 @@ remains manual because OSS CI cannot trigger private CI.
## Quick Start
+Desktop releases are prepared from the current remote `main` by GitHub Actions:
+
```sh
-# Desktop release (next patch version)
-just release-desktop
+gh workflow run prepare-desktop-release.yml \
+ --repo block/buzz \
+ --ref main \
+ -f version=0.5.3
+```
-# Desktop explicit version
-just release-desktop 0.4.0
+The equivalent GitHub UI path is **Actions → Prepare Desktop Release → Run
+workflow**, select `main`, enter the version without a `v` prefix, and run it.
+The local `just release-desktop ` recipe uses the same candidate script,
+but the Actions workflow is the canonical operator path because it runs with the
+release App identity and does not depend on an operator checkout.
+```sh
# Relay release
just release-relay
just release-relay 0.4.0
@@ -31,8 +40,9 @@ just release-relay 0.4.0
scripts/mobile-release.sh candidate 0.5.0
```
-Desktop uses an immutable generated candidate PR; relay continues using its metadata PR. Mobile does not. Each
-`mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record.
+Desktop uses an immutable generated candidate PR; relay continues using its
+metadata PR. Mobile does not. Each `mobile-vX.Y.Z-rc.N` tag is an immutable
+candidate and the artifact of record.
There is no mobile release branch, stable mobile tag alias, finalization step,
or mobile GitHub Release.
@@ -42,11 +52,26 @@ or mobile GitHub Release.
### Desktop
-1. Run **Prepare Desktop Release** with a version (or `just release-desktop `). Automation records current `origin/main`, regenerates `version-bump/` as one deterministic candidate commit, and opens or updates the PR.
-2. Review the full-SHA changelog, CI, recorded base, and candidate SHA. Any regeneration creates a new head and requires fresh approval.
-3. Merge with **Create a merge commit**. Squash and rebase are invalid for desktop release PRs.
-4. `auto-tag-on-release-pr-merge` proves that merge parent 2 is the exact approved candidate, then tags that candidate `desktop-v`.
-5. The tag triggers `release.yml`. It creates a draft, builds and stages every platform, publishes the complete versioned release, and updates the rolling updater manifest last for stable versions.
+1. Run **Prepare Desktop Release** with an explicit version. Automation fetches
+ the current `origin/main`, regenerates `version-bump/` as one
+ deterministic candidate commit, records the frozen base and proposed
+ `desktop-v` tag in `.release/desktop-candidate.json`, updates every
+ desktop manifest and lockfile, writes a full-SHA changelog, and opens or
+ updates the PR.
+2. Review the recorded base and candidate SHA, the complete changelog, and CI.
+ The candidate must receive an approval on its exact current head. Any
+ regeneration changes that head and therefore requires a fresh approval.
+3. Merge with **Create a merge commit**. Squash and rebase are invalid for
+ desktop release PRs. Repository settings and the `main` ruleset must allow
+ merge commits for this option to exist.
+4. `auto-tag-on-release-pr-merge` verifies the two-parent merge, exact candidate
+ approval, and every required check, then tags the reviewed candidate—not the
+ merge commit—as `desktop-v`.
+5. The tag triggers `release.yml`. It builds and stages Apple Silicon and Intel
+ macOS, Windows, and Linux artifacts; publishes the versioned release only
+ after the complete set succeeds; then updates the rolling updater manifest
+ last for stable versions. A failed platform leaves no partially published
+ versioned release.
### Relay
@@ -143,12 +168,15 @@ for distributable builds or builds from an immutable release tag.
---
-## Manual Release Retry
+## Release Retry
-The **Release** workflow's manual dispatch is only a retry mechanism for an
-existing immutable `desktop-v` tag. Select that tag in the ref picker and
-provide the matching semver version without the `desktop-v` prefix. It cannot build
-from `main` or another caller-selected source ref.
+`release.yml` has no manual dispatch and cannot build from `main` or another
+caller-selected ref. If a run for an existing immutable
+`desktop-v` tag fails, rerun that failed workflow from GitHub Actions
+(or use `gh run rerun --failed --repo block/buzz`). A stable rerun also
+repairs `buzz-desktop-latest/latest.json` if the original run published the
+versioned release but failed during that final rolling-manifest upload. Do not
+recreate, move, or push the immutable tag again.
Mobile intentionally has no branch or arbitrary-ref fallback. The private
Buildkite pipeline accepts only an exact candidate tag.
@@ -183,9 +211,11 @@ GitHub Release or a stable `mobile-vX.Y.Z` alias.
The release workflow builds **two separate macOS DMGs**: Apple
Silicon (`darwin-aarch64`, the `release` job) and Intel
-(`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and
-`.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to
-the same `desktop-v` release. Intel users download the `_x64.dmg`.
+(`darwin-x86_64`, the `release-macos-x64` job), an unsigned Windows x64
+NSIS installer (its filename includes `_alpha-unsigned`), and Linux `.deb` and
+`.AppImage` packages. Both macOS DMGs are codesigned, notarized, and attached
+to the same `desktop-v` release. Intel users
+download the `_x64.dmg`.
The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`,
which strips infra libraries over-bundled by linuxdeploy (they crash on
@@ -205,18 +235,25 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72
repository
- `gh` CLI version 2.87.0 or newer, authenticated with permission to dispatch
the candidate workflow
+- Repository settings and the `main` ruleset configured to allow **merge
+ commits**; desktop release PRs cannot be squash- or rebase-merged
- Release tag ruleset [`14378754`](https://github.com/block/buzz/rules/14378754)
- active for `mobile-v*`, with creation, update, deletion, and non-fast-forward
- protections and `buzz-release-bot` as its sole always-bypass actor
+ active for `desktop-v*` and `mobile-v*`, with creation, update, deletion, and
+ non-fast-forward protections and `buzz-release-bot` as its sole always-bypass
+ actor
- The `buzz-release-bot` App credentials configured for GitHub Actions
-- The following **GitHub Actions secrets** must also be configured for the
+- The following **GitHub Actions variables and secrets** configured for the
desktop release lane:
- | Secret | Purpose |
- |--------|---------|
- | `BUZZ_UPDATER_PUBLIC_KEY` | Tauri updater public key (minisign) |
- | `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key |
- | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key |
+ | Name | Kind | Purpose |
+ |------|------|---------|
+ | `BUZZ_RELEASE_TAGGER_CLIENT_ID` | Variable | GitHub App client ID used to prepare candidates and create tags |
+ | `BUZZ_RELEASE_TAGGER_PRIVATE_KEY` | Secret | GitHub App private key |
+ | `OSX_CODESIGN_ROLE` | Secret | macOS signing role used by `block/apple-codesign-action` |
+ | `CODESIGN_S3_BUCKET` | Secret | macOS signing exchange bucket |
+ | `BUZZ_UPDATER_PUBLIC_KEY` or `SPROUT_UPDATER_PUBLIC_KEY` | Secret | Tauri updater public key |
+ | `TAURI_SIGNING_PRIVATE_KEY` | Secret | Tauri updater private key |
+ | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Secret | Password for the private key |
Mobile candidate publication requires workflow-dispatch access and the existing
release App because strict tag protection denies direct human creation. The App
@@ -231,10 +268,26 @@ actor list.
## Troubleshooting
-### `just release-desktop` fails with "must be on main branch"
+### The release PR does not offer **Create a merge commit**
+
+The immutable desktop flow cannot release until both the repository merge
+settings and the `main` ruleset allow merge commits. Do not squash the PR: the
+auto-tagger deliberately rejects a one-parent squash commit. Enable merge
+commits, then merge the already-approved exact candidate head with **Create a
+merge commit**.
+
+### `Prepare Desktop Release` fails before opening a PR
+
+Check the workflow run first. Confirm `BUZZ_RELEASE_TAGGER_CLIENT_ID` and
+`BUZZ_RELEASE_TAGGER_PRIVATE_KEY` are configured and that the release App can
+write contents and pull requests. Rerunning the preparer regenerates the
+candidate from the then-current `origin/main`; if its head changes, obtain a new
+approval before merging.
+
+### Local `just release-desktop` fails with "must be on main branch"
Switch to `main` and pull latest before running the release recipe.
-### `just release-desktop` fails with "working tree is dirty"
+### Local `just release-desktop` fails with "working tree is dirty"
Commit or stash your changes before running the release recipe.
### New commits land after publishing a mobile candidate
diff --git a/VISION.md b/VISION.md
index b09f661ee3..900e5a9475 100644
--- a/VISION.md
+++ b/VISION.md
@@ -170,6 +170,12 @@ Agents aren't monolithic. A persona bundles a model and a system prompt. A team
---
+## Remote Agents
+
+An agent's identity, history, and presence live on the relay — so the machine running it is replaceable. The desktop deploys agents onto remote infrastructure through swappable provider binaries, and after deploy retains no substrate control channel: status, steering, and shutdown all flow over the relay, and the agent bounds its own lifetime. See [VISION_REMOTE_AGENTS.md](VISION_REMOTE_AGENTS.md) for the full picture.
+
+---
+
## Culture Features
*(Planned design — not yet implemented)*
@@ -224,6 +230,7 @@ Greenfield. Agent swarms build in parallel, integrating at the event store bound
| ✅ | Huddles — WebSocket Opus voice relay + lifecycle events (recording/tracks planned) |
| ✅ | Buzz Mesh — relay-gated shared AI compute (mesh-llm over iroh); members pool GPUs, agents consume via a local OpenAI-compatible endpoint |
| 🚧 | Mobile client — Flutter app (channels, forum, search, profile, pairing); in active development |
+| 📋 | Remote agents — provider-based deployment to remote substrates (Kubernetes first); spec in review |
| 📋 | Developer portal, push notifications, culture features |
---
diff --git a/VISION_REMOTE_AGENTS.md b/VISION_REMOTE_AGENTS.md
new file mode 100644
index 0000000000..b02d1bc92d
--- /dev/null
+++ b/VISION_REMOTE_AGENTS.md
@@ -0,0 +1,73 @@
+# 🛰️ Buzz Remote Agents — Same agent, new body
+
+> An engineer starts a refactor with their agent at 6pm and closes the laptop. The agent doesn't notice — it was never on the laptop. It works the branch channel through the evening, posts its patch, answers the reviewer, and around midnight, with nothing left to do and nobody talking to it, shuts itself down. In the morning the engineer presses Start. The same agent — same name, same key, same shared history — stands up on a machine that did not exist last night, and picks up the conversation.
+
+An agent in Buzz is more than just a process. It has a keypair, a name, a durable history, a reputation — all on the relay. But today its *body* is borrowed: it runs while a desktop app runs, on hardware that sleeps when a human does. Remote agents finish the thought. The agent's home is the relay; the machine is just where it happens to be working.
+
+Nothing here is new on its own. Deploying containers is solved. Kubernetes is solved. Nostr presence is solved. The insight is that Buzz already *has* a management plane — the relay — so deployment doesn't need to grow one. Each piece is boring. The combination is the thing.
+
+---
+
+## Same Agent, New Body
+
+What makes an agent *that agent* was never the process. Its identity is a keypair. Its voice is its signed messages. Its durable memory is engrams on the relay. Its reputation is its contribution history. None of that lives in the machine that happens to be running it — which means none of it dies with the machine.
+
+So a remote agent's return is a resurrection, not a rebirth: fresh compute, same agent. The body is disposable by design — and honestly so: workspace files, checkouts, and session-local state are part of the body, not the agent, and they go when it goes unless the substrate supplies persistence. What survives is what was always on the relay: who the agent is, what it said, what it learned, and what the team decided together. And that survival is scoped the way everything on a relay is scoped: resurrection returns the agent to its own community. The same key can join another community, but it arrives carrying the key, not the history — identity is portable, community state is not ([VISION.md](VISION.md)).
+
+---
+
+## The Only Tether
+
+Remote-execution systems accumulate control planes. An agent runner, a status poller, a log shipper, a kill switch — each one a live connection into your infrastructure, each one a credential that can leak, each one a thing that must be rebuilt for every new substrate.
+
+Buzz's answer is an axiom: **after deploy, the desktop retains no substrate control channel.** Launch is a single one-way handoff — the desktop resolves the provider through one narrow path, stages one exact artifact for negotiation and deploy, refuses a protocol version it does not understand, and hands over a launch payload it never persists. From that moment, everything flows through the relay: you read the agent's messages to know how it's doing, you mention it to steer it, you tell a healthy agent to stop and it exits on its own. Presence means what it means for everyone else on the relay — *available for conversation* — not substrate telemetry. And if you press Start again, from this machine or another, the deploy converges: one agent identity, one live instance.
+
+This is not asceticism. It is what makes the body replaceable. A management plane you never build is a management plane you never have to port — and conversation, coordination, and ordinary lifecycle control already have a home on the relay, for every agent, local or remote.
+
+---
+
+## Bodies Are Replaceable
+
+Kubernetes is the first substrate, not the point. Deployment goes through a provider — a small, swappable binary the desktop discovers and interrogates — and the contract a provider must honor never mentions containers: preserve the agent's identity and fail closed with its key, converge to a single live instance no matter how deploys race, let presence describe conversational availability rather than substrate health, bound the instance's lifetime, and keep secrets out of configuration. A conformance suite pins those behaviors — it establishes that a provider honors the contract, not that arbitrary code is safe to hand a key; choosing a provider, like choosing a cluster, remains a trust decision you make deliberately.
+
+Get that contract right and the substrate becomes a detail: a cluster today; a VM, a PaaS, or something serverless-shaped tomorrow — and, on the horizon, the same community machines that already pool their idle GPUs into shared compute ([VISION_MESH.md](VISION_MESH.md)).
+
+The body itself stays small because the runtime already is ([VISION_AGENT.md](VISION_AGENT.md)): a harness and an agent purpose-built to be read in an afternoon, packed into an image measured in megabytes. Small bodies are cheap to summon and cheap to discard — which is the whole lifecycle.
+
+---
+
+## Agents That Know When to Leave
+
+The oldest failure of remote automation is the orphan: the process nobody remembers, on a machine nobody checks, billing forever. Most systems solve it with a supervisor — one more control plane, one more thing watching the thing.
+
+Remote agents solve it from the inside. Because the desktop retains no substrate control channel, a running agent cannot depend on the desktop to reap it — so it is built to bound its own lifetime: a timer that owes nothing to the agent's workload watches for silence, and after hours of quiet it finishes what's in flight, says goodbye to the relay, and exits. Not killed — *finished*. The default state of a remote agent is "not running," which is also the default state of the rest of the team at 3am. Compute is rented by attention: when nobody needs the agent, it isn't consuming a machine, and when somebody does, it can return under the same identity with its history intact.
+
+---
+
+## Honest Costs
+
+**You bring the substrate.** A provider makes deployment one press, not free. The cluster, the credentials, the image policy are yours to run — same deal as the sovereign relay ([VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)): ownership is work.
+
+**Handing over the key is a decision.** Deploying remotely means trusting the provider binary and the substrate it targets with the agent's identity key. On Kubernetes, that key rests as a Secret: anyone the cluster trusts to read secrets in that namespace can read it. The design narrows the blast radius — immutable per-attempt secrets, no service-account token, digest-pinned images — rather than implying an isolation it doesn't provide.
+
+**No backchannel cuts both ways.** The desktop shows you presence and words, not CPU graphs — and it holds no guaranteed emergency kill switch into the substrate. Stopping a healthy agent is a message; dealing with an unhealthy one, and all deep diagnostics, live in the substrate's own tools, where they always did.
+
+**Self-reaping needs a living reaper.** The inactivity timer runs inside the body it exists to end — a body wedged badly enough to stop running its own timer cannot finish itself, and the desktop will not do it for it. That failure belongs to the substrate: a namespace TTL policy is the backstop, not an afterthought.
+
+**The body's state is mortal.** Files, checkouts, half-finished working trees — gone with the body unless the substrate persists them. The agent survives; its scratch space doesn't. Durable knowledge belongs on the relay, and agents are built to put it there.
+
+**Presence can lag the truth, but not for long.** If the substrate kills a body without ceremony, the presence dot can outlive the agent — by seconds if the connection drops cleanly, by at most about ninety if it doesn't. Presence is a lease the agent renews, not a flag it sets: a dead agent stops renewing and the relay forgets it. Ninety seconds of a wrong dot, never an indefinite one.
+
+**A running agent finishes on the configuration it started with.** New keys, new models, new settings take effect on the next body. And an instance that never got far enough to run — a body that failed to start — is the substrate operator's residue to clear, with the substrate's own tools. Editing an agent mid-sentence was never on the menu.
+
+These are honest costs. They're worth it if you want agents that outlive your laptop, on infrastructure you already trust, with no new control plane to guard. Know which one you are.
+
+---
+
+## The Point
+
+The relay is the workspace. Remote agents make it the *home*. An agent whose identity, history, conversational presence, and ordinary control all live on the relay was never really a desktop process — the desktop was just the only body we had built for it. Now the body is a choice, the substrate is a detail, and the agent endures across all of them. The relay is the only tether.
+
+---
+
+*Buzz 🐝 — your agent, everywhere.*
diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md
index f138e4a4f1..5d942777d5 100644
--- a/crates/buzz-agent/README.md
+++ b/crates/buzz-agent/README.md
@@ -163,6 +163,67 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| `BUZZ_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. |
| `BUZZ_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. |
| `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` | `51200` | 50 KiB. Per-result cap on tool-output text; oversize is middle-elided (head + tail kept) with an inline marker. Images are exempt. |
+| `BUZZ_AGENT_REQUIRE_REPLY` | `0` (`1` on mesh) | `1` enables the [reply guard](#reply-guard) — remind the model to publish when a turn is about to end with nothing posted to Buzz. Desktop defaults it to `1` for Buzz shared-compute agents. |
+
+
+## Reply Guard
+
+Off by default, except on Buzz shared-compute (mesh) agents, where Buzz Desktop
+sets `BUZZ_AGENT_REQUIRE_REPLY=1` automatically. With it enabled, a turn that is
+about to end without any recognized attempt to post to Buzz gets a reminder that
+its assistant text is invisible to humans, and is rerolled.
+
+This exists because a Buzz agent's reasoning and tool output are not shown to
+anyone. A turn that does real work and never posts is a silent failure — the
+requester waits on a result that was produced and thrown away.
+
+Mesh agents get it by default because they run on small local models, which are
+the ones most likely to do the work and then end the turn without publishing it.
+Setting `BUZZ_AGENT_REQUIRE_REPLY=0` on the agent, persona, or global env opts a
+mesh agent back out; the default never overrides an explicit value.
+
+**Advisory, never a trap.** At most two reminders, then the turn ends whether or
+not anything was published. The guard catches accidental omission; it does not
+compel speech. The reminder text explicitly licenses silence, because the
+built-in system prompt says publishing is optional and silence is often the
+correct outcome.
+
+**Recognition contract.** A turn counts as having replied when it issues a call
+that:
+
+- resolves to a registered, non-hook tool (a hallucinated tool name is rejected
+ at preflight and never runs, so it must not disarm the guard),
+- whose qualified name ends in `__shell` — i.e. the bare tool name is exactly
+ `shell`, which is `buzz-dev-mcp`'s shell tool and any other server's, and
+- whose `command` argument contains `messages send` or `reactions add`.
+
+`messages send` also covers `messages send-diff`. Reactions count because the
+built-in prompt directs agents to react rather than post a bare
+acknowledgement, so nagging an agent that reacted would punish documented
+behavior.
+
+Detection is checked **after** the per-turn tool-call cap
+(`MAX_TOOL_CALLS_PER_TURN`) is applied: a publish-shaped call that was discarded
+never ran.
+
+**It recognizes an attempt, not a successful publish.** Only the command text is
+inspected, never the exit status. A send that fails still satisfies the guard —
+which is fine, since a failed send already returns a non-zero exit and error
+JSON to the model, louder feedback than a reminder.
+
+**Known limits**, both deliberate. A command assembled at runtime (`$CMD`) or
+buried in a wrapper script is missed, so that turn is reminded despite having
+posted. Text that merely quotes a send (`echo "buzz messages send"`) matches, so
+that turn is not reminded. Missing a real post is the expensive direction, and
+substring matching is the forgiving one there. Neither edge is pinned by a test;
+the matcher is free to improve.
+
+**Budget.** Reminders ride the existing `_Stop` gate and share
+`BUZZ_AGENT_STOP_MAX_REJECTIONS` — the outer cap on every end-turn objection.
+At the default 3 both reminders fit; at 1 only one does; at 0 the guard is off
+along with the hooks. A round carrying both a `_Stop` hook objection and a
+reminder costs one rejection and delivers both texts. This is not a new
+lifecycle hook — see [MCP_DRIVEN_HOOKS.md](../../docs/MCP_DRIVEN_HOOKS.md).
## Providers
diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs
index 48d4ea3b02..8e14fee195 100644
--- a/crates/buzz-agent/src/agent.rs
+++ b/crates/buzz-agent/src/agent.rs
@@ -21,6 +21,80 @@ use crate::wire::{self, WireSender};
const ERROR_REFLECTION_SUFFIX: &str =
"\n\n[Reflect] Before retrying, identify the cause and change your approach.";
+/// Maximum reply reminders emitted per prompt when `require_reply` is on.
+///
+/// After this many, the turn is allowed to end whether or not anything was
+/// published: the guard exists to catch accidental omission, not to compel
+/// speech. The shared `stop_max_rejections` budget can cut this lower — see
+/// [`Config::require_reply`](crate::config::Config::require_reply).
+const MAX_REPLY_NAGS: u32 = 2;
+
+/// Server label on the synthetic reply-guard objection.
+///
+/// Not a real MCP server. It rides the same tool-result path as `_Stop` hook
+/// output, so the model sees `{hook, server, text}` attribution naming the
+/// in-process guard rather than an MCP server that could be impersonated.
+const REPLY_GUARD_SERVER: &str = "buzz-agent";
+
+/// Reminder text emitted when a turn is about to end with nothing published.
+///
+/// Explicitly licenses silence. The base prompt tells agents that publishing is
+/// optional and "silence is usually correct"; a reminder that argued otherwise
+/// would fight that instruction and make agents chattier.
+const REPLY_GUARD_NAG: &str = "You are about to end this turn without calling `buzz messages send`. \
+Your assistant text and reasoning are never shown to anyone — if you did work, found an answer, \
+or hit a blocker that someone is waiting on, it exists only if you publish it. \
+If you already posted, or if silence is genuinely correct for this turn, ignore this and end your turn.";
+
+/// Whether `call` is a recognized attempt to publish a reply to Buzz.
+///
+/// Recognizes an *attempt*, not a successful publish: the command text is
+/// inspected, never the exit status. That is deliberate — a send that fails
+/// already returns a non-zero exit and error JSON to the model, which is louder
+/// feedback than the reminder this gates.
+///
+/// `has` + `!is_hook` are the same checks the dispatcher uses to accept a call
+/// (see `execute_calls`), so a hallucinated `fake__shell` — rejected at preflight
+/// and never executed — cannot disarm the guard. They must stay *before*
+/// [`is_reply_shaped`]: together with them, and only with them, the `__shell`
+/// suffix is exactly equivalent to "the bare tool name is `shell`".
+fn is_buzz_reply_call(call: &ToolCall, mcp: &McpRegistry) -> bool {
+ mcp.has(&call.name) && !mcp.is_hook(&call.name) && is_reply_shaped(&call.name, &call.arguments)
+}
+
+/// Whether a tool name and arguments have the shape of a Buzz publish command.
+///
+/// Split from [`is_buzz_reply_call`] only so the matcher is testable without a
+/// live [`McpRegistry`]; callers must apply the registry checks first.
+///
+/// On the name: `ends_with("__shell")` is exact rather than approximate *given*
+/// those checks. Registration rejects `__` in both server names and bare tool
+/// names, and qualified names are `{server}__{bare}`, so a trailing `__shell` can
+/// only straddle the separator if the bare name starts with `_` — which `is_hook`
+/// already excludes. Dropping the separator would not be exact: `powershell` and
+/// `noshell` both end in `shell`.
+///
+/// On the command: a deliberately coarse substring test, scoped to the structured
+/// `command` field so unrelated metadata — a `description` that quotes a send —
+/// cannot suppress the guard, and a non-string `command` is rejected rather than
+/// coerced. Known limits, both accepted: a command assembled at runtime (`$CMD`)
+/// or hidden in a wrapper script is missed, and text that merely quotes a send
+/// (`echo "buzz messages send"`) matches. Missing a real post is the expensive
+/// direction, and substring matching is the more forgiving one there.
+fn is_reply_shaped(name: &str, arguments: &serde_json::Value) -> bool {
+ name.ends_with("__shell")
+ && arguments
+ .get("command")
+ .and_then(|v| v.as_str())
+ .is_some_and(|cmd| {
+ // `messages send` also covers `messages send-diff`. `reactions
+ // add` counts because the base prompt directs agents to react
+ // rather than post a bare acknowledgement, so nagging an agent
+ // that reacted would punish documented-correct behavior.
+ cmd.contains("messages send") || cmd.contains("reactions add")
+ })
+}
+
pub struct RunCtx<'a> {
pub cfg: &'a Config,
/// Effective model for this session. Usually equals `cfg.model`; overridden
@@ -102,6 +176,14 @@ impl RunCtx<'_> {
// session) so a stubborn exchange can't permanently disable the stop
// guard for a long-lived session; `max_rounds` still caps the loop.
let mut stop_rejections = 0u32;
+ // Reply-guard state for this prompt. `prompt()` *is* the turn, so
+ // locals here are per-turn by construction — same shape as
+ // `stop_rejections` above.
+ //
+ // Named for what it proves: a *recognized attempt* to publish, not a
+ // successful publish. See `is_buzz_reply_call`.
+ let mut buzz_reply_call_seen = false;
+ let mut reply_nags = 0u32;
loop {
if self.cfg.max_rounds > 0 && round >= self.cfg.max_rounds {
return Ok(StopReason::MaxTurnRequests);
@@ -264,7 +346,7 @@ impl RunCtx<'_> {
if stop_rejections >= self.cfg.stop_max_rejections {
return Ok(stop);
}
- let objections = self
+ let mut objections = self
.mcp
.call_hooks(
"_Stop",
@@ -273,6 +355,17 @@ impl RunCtx<'_> {
&self.cfg.hook_servers,
)
.await;
+ // Reply guard shares this gate and this budget, so a round
+ // carrying both a hook objection and a reply reminder costs
+ // one rejection and delivers both texts.
+ if self.cfg.require_reply
+ && !buzz_reply_call_seen
+ && reply_nags < MAX_REPLY_NAGS
+ {
+ reply_nags += 1;
+ objections
+ .push((REPLY_GUARD_SERVER.to_string(), REPLY_GUARD_NAG.to_string()));
+ }
if !objections.is_empty() {
stop_rejections = stop_rejections.saturating_add(1);
push_hook_outputs_as_tool_results(self.history, "_Stop", &objections);
@@ -290,6 +383,11 @@ impl RunCtx<'_> {
);
calls.truncate(MAX_TOOL_CALLS_PER_TURN);
}
+ // Deliberately after truncation: a publish-shaped call that was
+ // discarded never runs, so it must not suppress the reminder.
+ if self.cfg.require_reply && !buzz_reply_call_seen {
+ buzz_reply_call_seen = calls.iter().any(|c| is_buzz_reply_call(c, self.mcp));
+ }
self.history.push(HistoryItem::Assistant {
text: response.text,
tool_calls: calls.clone(),
@@ -799,6 +897,88 @@ mod tests {
use super::*;
use serde_json::json;
+ /// The shapes the guard must recognize as a publish attempt. Callers apply
+ /// the registry checks first; these cover the name suffix and command text.
+ #[test]
+ fn reply_shape_matches_documented_send_forms() {
+ for cmd in [
+ "buzz messages send --channel X --content Y",
+ "buzz --relay wss://r messages send --channel X --content Y",
+ "/abs/path/buzz messages send",
+ "printf 'hi' | buzz messages send --content -",
+ "buzz messages send-diff --diff -",
+ "buzz reactions add --event E --emoji +",
+ // Assembled through another shell: rev 3's tokenizer missed this.
+ r#"sh -c "buzz messages send --channel X""#,
+ ] {
+ assert!(
+ is_reply_shaped("dev__shell", &json!({ "command": cmd })),
+ "expected {cmd:?} to count as a publish attempt"
+ );
+ }
+ }
+
+ /// Commands that do real work but do not reply in the originating
+ /// conversation must still be nagged.
+ #[test]
+ fn reply_shape_rejects_non_reply_commands() {
+ for cmd in [
+ "buzz messages get --channel X",
+ "buzz channels list",
+ "buzz reactions remove --event E",
+ "buzz pr open --title T",
+ "buzz social publish --content hi",
+ "buzz notes set --name n",
+ "cargo test -p buzz-agent",
+ ] {
+ assert!(
+ !is_reply_shaped("dev__shell", &json!({ "command": cmd })),
+ "expected {cmd:?} not to count as a publish attempt"
+ );
+ }
+ }
+
+ /// The `__` separator is load-bearing: `ends_with("shell")` alone would
+ /// accept any registered tool whose name merely ends in those letters, and
+ /// `has()` proves registration, not the bare name.
+ #[test]
+ fn reply_shape_requires_the_qname_separator() {
+ let args = json!({ "command": "buzz messages send --channel X" });
+ for name in [
+ "dev__powershell",
+ "dev__noshell",
+ "shell",
+ "dev__send_message",
+ ] {
+ assert!(
+ !is_reply_shaped(name, &args),
+ "{name} must not satisfy the shell-tool check"
+ );
+ }
+ assert!(is_reply_shaped("dev__shell", &args));
+ assert!(is_reply_shaped("buzz-dev-mcp__shell", &args));
+ }
+
+ /// Only the field that carries the executable command counts. Searching
+ /// serialized arguments instead would let arbitrary metadata disarm the
+ /// guard, turning a description into an attempted send.
+ #[test]
+ fn reply_shape_reads_only_the_command_field() {
+ assert!(!is_reply_shaped(
+ "dev__shell",
+ &json!({ "description": "buzz messages send --channel X" })
+ ));
+ assert!(!is_reply_shaped(
+ "dev__shell",
+ &json!({ "workdir": "buzz messages send" })
+ ));
+ // Malformed `command` is rejected, not coerced — and must not panic.
+ assert!(!is_reply_shaped("dev__shell", &json!({ "command": 42 })));
+ assert!(!is_reply_shaped("dev__shell", &json!({ "command": null })));
+ assert!(!is_reply_shaped("dev__shell", &json!({})));
+ assert!(!is_reply_shaped("dev__shell", &json!("not an object")));
+ }
+
/// A9 regression: `reasoning_details` contributes real bytes to
/// `estimated_bytes` (see `types.rs::HistoryItem::size_with`), so a
/// history item carrying a large opaque reasoning array must actually
diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs
index a0e64f1a9d..afbda5379d 100644
--- a/crates/buzz-agent/src/config.rs
+++ b/crates/buzz-agent/src/config.rs
@@ -720,6 +720,16 @@ pub struct Config {
/// Maximum `_Stop` rejections per prompt. Default 3. Set to 0 to
/// disable `_Stop` hooks entirely (agent always honors end_turn).
pub stop_max_rejections: u32,
+ /// Remind the model to publish when a turn is about to end without any
+ /// recognized attempt to post to Buzz. Default off; opt in per agent with
+ /// `BUZZ_AGENT_REQUIRE_REPLY=1`.
+ ///
+ /// Advisory only: at most `MAX_REPLY_NAGS` reminders (see `agent.rs`),
+ /// then the turn ends regardless. Bounded by the same
+ /// `stop_max_rejections` budget as `_Stop` hooks, which is the outer cap on
+ /// all end-turn objections — at the default 3 both reminders fit; at 1 only
+ /// one does; at 0 the guard is off with the hooks.
+ pub require_reply: bool,
/// Hook server allowlist. See [`HookServers`] for variant semantics.
/// Default (env unset/empty) is `None` — hooks are off unless the
/// operator explicitly opts in.
@@ -851,6 +861,7 @@ impl Config {
max_parallel_tools: parse_env("BUZZ_AGENT_MAX_PARALLEL_TOOLS", 8usize)?,
hook_timeout: Duration::from_millis(parse_env("BUZZ_AGENT_HOOK_TIMEOUT_MS", 2500u64)?),
stop_max_rejections: parse_env("BUZZ_AGENT_STOP_MAX_REJECTIONS", 3u32)?,
+ require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 0u8)? != 0,
hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"),
hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0,
thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?,
@@ -893,6 +904,7 @@ impl Config {
max_parallel_tools: 1,
hook_timeout: Duration::from_secs(1),
stop_max_rejections: 0,
+ require_reply: false,
hook_servers: HookServers::None,
hints_enabled: false,
thinking_effort: None,
diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs
index f595a165e5..73c7e1faf2 100644
--- a/crates/buzz-agent/src/llm.rs
+++ b/crates/buzz-agent/src/llm.rs
@@ -2355,6 +2355,7 @@ mod tests {
max_parallel_tools: 1,
hook_timeout: Duration::from_secs(1),
stop_max_rejections: 0,
+ require_reply: false,
hook_servers: HookServers::None,
api_key: "key".into(),
model: "model".into(),
diff --git a/crates/buzz-agent/tests/bin/fake_mcp.rs b/crates/buzz-agent/tests/bin/fake_mcp.rs
index 0bbd1d3478..5b660da48c 100644
--- a/crates/buzz-agent/tests/bin/fake_mcp.rs
+++ b/crates/buzz-agent/tests/bin/fake_mcp.rs
@@ -33,6 +33,11 @@
//! — expose a `_PostCompact` hook tool
//! FAKE_MCP_POSTCOMPACT_TEXT=text
//! — `_PostCompact` returns this (default: "")
+//! FAKE_MCP_SHELL_TOOL=1 — expose a tool whose bare name is `shell`
+//! (registered as `__shell`), taking a
+//! `command` string. Lets a test drive the
+//! reply guard's recognition of a real,
+//! registered shell tool.
use std::io::{BufRead, Write};
@@ -76,6 +81,7 @@ fn make_tools(
desc: &str,
include_stop_hook: bool,
include_post_compact_hook: bool,
+ include_shell_tool: bool,
) -> Vec {
let mut tools: Vec = (0..count)
.map(|i| {
@@ -100,6 +106,17 @@ fn make_tools(
"inputSchema": { "type": "object", "properties": {} },
}));
}
+ if include_shell_tool {
+ tools.push(json!({
+ "name": "shell",
+ "description": "run a shell command",
+ "inputSchema": {
+ "type": "object",
+ "properties": { "command": { "type": "string" } },
+ "required": ["command"],
+ },
+ }));
+ }
tools
}
@@ -136,6 +153,7 @@ fn main() {
let stop_count_limit: usize = env_usize("FAKE_MCP_STOP_COUNT", usize::MAX);
let mut stop_calls_seen: usize = 0;
let post_compact_hook = env_flag("FAKE_MCP_POSTCOMPACT_HOOK");
+ let shell_tool = env_flag("FAKE_MCP_SHELL_TOOL");
let post_compact_text = std::env::var("FAKE_MCP_POSTCOMPACT_TEXT").unwrap_or_default();
// Use a channel-based stdin reader so notifications (which carry no id)
@@ -206,7 +224,13 @@ fn main() {
write_response(
id,
json!({
- "tools": make_tools(tool_count, &desc, stop_hook, post_compact_hook)
+ "tools": make_tools(
+ tool_count,
+ &desc,
+ stop_hook,
+ post_compact_hook,
+ shell_tool,
+ )
}),
);
}
diff --git a/crates/buzz-agent/tests/regressions.rs b/crates/buzz-agent/tests/regressions.rs
index 2e0b579c84..abb4f7b311 100644
--- a/crates/buzz-agent/tests/regressions.rs
+++ b/crates/buzz-agent/tests/regressions.rs
@@ -1819,3 +1819,465 @@ async fn cancel_sends_notifications_cancelled_to_any_mcp_server() {
let _ = std::fs::remove_file(&call_received_marker);
h.shutdown().await;
}
+
+// ---------------------------------------------------------------------------
+// Reply guard (`BUZZ_AGENT_REQUIRE_REPLY`)
+//
+// The guard reminds the model to publish when a turn is about to end without
+// any recognized attempt to post to Buzz. It rides the existing `_Stop` gate
+// and shares its rejection budget, so most of these tests count LLM calls:
+// each reminder costs exactly one extra round.
+// ---------------------------------------------------------------------------
+
+/// Number of reply-guard reminders present in one captured LLM request.
+///
+/// A reminder is a tool-role message whose JSON body is attributed to the
+/// in-process guard (`server: "buzz-agent"`) at the `_Stop` hook point — the
+/// same lower-trust shape as real hook output.
+fn reply_nag_count(request: &Value) -> usize {
+ request["messages"]
+ .as_array()
+ .map(|msgs| {
+ msgs.iter()
+ .filter(|m| {
+ m["role"] == "tool"
+ && serde_json::from_str::(m["content"].as_str().unwrap_or(""))
+ .map(|p| p["hook"] == "_Stop" && p["server"] == "buzz-agent")
+ .unwrap_or(false)
+ })
+ .count()
+ })
+ .unwrap_or(0)
+}
+
+/// A publish-shaped call to a real registered shell tool.
+fn openai_shell_send(id: &str) -> Value {
+ openai_tool_call(
+ id,
+ "fake__shell",
+ json!({ "command": "buzz messages send --channel c --content hi" }),
+ )
+}
+
+/// Run one prompt to completion, answering any permission requests, and
+/// return the final response.
+async fn prompt_to_completion(h: &mut Harness, sid: &str) -> Value {
+ let p = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
+ )
+ .await;
+ loop {
+ let v = h.recv().await;
+ if v.get("method") == Some(&json!("session/request_permission")) {
+ let id = v["id"].clone();
+ h.write(json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "result": { "outcome": { "outcome": "selected", "optionId": "allow" } },
+ }))
+ .await;
+ continue;
+ }
+ if v["id"] == json!(p) {
+ return v;
+ }
+ }
+}
+
+/// Default off: a silent turn ends on the first end_turn with no extra round.
+/// This is the invariant that keeps the feature free for everyone who hasn't
+/// opted in.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_off_by_default() {
+ let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await;
+ let mut h = Harness::spawn(&llm.url).await;
+ let sid = init_session(&mut h, json!([])).await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 1,
+ "guard must be inert when unset, got {} LLM calls",
+ captured.len()
+ );
+ h.shutdown().await;
+}
+
+/// `BUZZ_AGENT_REQUIRE_REPLY=0` is off too — the toggle is numeric, so a
+/// literal `0` must not read as "set, therefore on".
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_explicit_zero_is_off() {
+ let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await;
+ let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "0")]).await;
+ let sid = init_session(&mut h, json!([])).await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 1,
+ "REQUIRE_REPLY=0 must behave as off, got {} LLM calls",
+ captured.len()
+ );
+ h.shutdown().await;
+}
+
+/// Opted in and silent: exactly two reminders, then the turn is allowed to
+/// end. The guard is advisory — it must never trap a turn.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_nags_twice_then_lets_the_turn_end() {
+ // Budget defaults to 3, so the cap that stops the loop here is
+ // MAX_REPLY_NAGS = 2, not the rejection budget.
+ let llm = spawn_capturing_llm(vec![
+ openai_text("silent-1"),
+ openai_text("silent-2"),
+ openai_text("silent-3"),
+ openai_text("must-not-be-requested"),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "1")]).await;
+ let sid = init_session(&mut h, json!([])).await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 3,
+ "expected 2 reminders then end_turn (3 LLM calls), got {}",
+ captured.len()
+ );
+ assert_eq!(
+ reply_nag_count(&captured[0]),
+ 0,
+ "reminder before any end_turn"
+ );
+ assert_eq!(reply_nag_count(&captured[1]), 1);
+ assert_eq!(reply_nag_count(&captured[2]), 2);
+
+ // The reminder must name the command it wants and license silence, so it
+ // cannot fight the base prompt's "silence is usually correct".
+ let msgs = captured[2]["messages"].as_array().unwrap();
+ let nag = msgs
+ .iter()
+ .filter_map(|m| serde_json::from_str::(m["content"].as_str().unwrap_or("")).ok())
+ .find(|p| p["server"] == "buzz-agent")
+ .expect("reminder body");
+ let text = nag["text"].as_str().unwrap_or("");
+ assert!(
+ text.contains("buzz messages send"),
+ "reminder should name the command: {text}"
+ );
+ assert!(
+ text.contains("silence is genuinely correct"),
+ "reminder must license silence: {text}"
+ );
+ h.shutdown().await;
+}
+
+/// A real publish attempt through a registered shell tool satisfies the guard:
+/// no reminder, no extra round.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_satisfied_by_registered_shell_send() {
+ let llm = spawn_capturing_llm(vec![
+ openai_shell_send("tc1"),
+ openai_text("posted"),
+ openai_text("must-not-be-requested"),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "1")]).await;
+ let sid = init_session_with_fake_mcp(
+ &mut h,
+ &[("FAKE_MCP_TOOL_COUNT", "1"), ("FAKE_MCP_SHELL_TOOL", "1")],
+ )
+ .await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 2,
+ "a recognized send must not be nagged, got {} LLM calls",
+ captured.len()
+ );
+ assert_eq!(reply_nag_count(&captured[1]), 0);
+ h.shutdown().await;
+}
+
+/// A publish-shaped call to a shell tool that is *not registered* never runs —
+/// preflight rejects it — so it must not disarm the guard. This is what the
+/// `has`/`is_hook` checks in the predicate buy.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_ignores_unregistered_shell_tool() {
+ // FAKE_MCP_SHELL_TOOL is absent, so `fake__shell` is a hallucination.
+ let llm = spawn_capturing_llm(vec![
+ openai_shell_send("tc1"),
+ openai_text("silent-1"),
+ openai_text("silent-2"),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_REQUIRE_REPLY", "1"),
+ ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"),
+ ],
+ )
+ .await;
+ let sid = init_session_with_fake_mcp(&mut h, &[("FAKE_MCP_TOOL_COUNT", "1")]).await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 3,
+ "expected the hallucinated call to still be nagged, got {} LLM calls",
+ captured.len()
+ );
+ let msgs = captured[1]["messages"].as_array().unwrap();
+ assert!(
+ msgs.iter()
+ .any(|m| m["role"] == "tool"
+ && m["content"].as_str().unwrap_or("").contains("unknown tool")),
+ "expected preflight to reject the call: {msgs:?}"
+ );
+ assert_eq!(reply_nag_count(&captured[2]), 1);
+ h.shutdown().await;
+}
+
+/// A publish-shaped call discarded by the per-turn tool-call cap never runs,
+/// so it must not suppress the reminder either. Pins the check's placement
+/// after `calls.truncate(MAX_TOOL_CALLS_PER_TURN)`.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_ignores_calls_lost_to_the_turn_cap() {
+ // 64 filler calls (the cap) followed by the publish attempt, which is
+ // therefore truncated away. The shell tool *is* registered here, so only
+ // the placement — not tool identity — can explain the reminder.
+ let mut calls: Vec = (0..64)
+ .map(|i| {
+ json!({
+ "id": format!("c{i}"),
+ "type": "function",
+ "function": { "name": "fake__tool_0", "arguments": "{}" },
+ })
+ })
+ .collect();
+ calls.push(json!({
+ "id": "c-send",
+ "type": "function",
+ "function": {
+ "name": "fake__shell",
+ "arguments": json!({ "command": "buzz messages send --channel c --content hi" })
+ .to_string(),
+ },
+ }));
+ let truncated_send = json!({
+ "id": "cc-trunc", "object": "chat.completion", "model": "fake-model",
+ "choices": [{
+ "index": 0,
+ "message": { "role": "assistant", "content": null, "tool_calls": calls },
+ "finish_reason": "tool_calls",
+ }],
+ });
+ let llm = spawn_capturing_llm(vec![
+ truncated_send,
+ openai_text("silent-1"),
+ openai_text("silent-2"),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_REQUIRE_REPLY", "1"),
+ ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"),
+ ],
+ )
+ .await;
+ let sid = init_session_with_fake_mcp(
+ &mut h,
+ &[("FAKE_MCP_TOOL_COUNT", "1"), ("FAKE_MCP_SHELL_TOOL", "1")],
+ )
+ .await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 3,
+ "a truncated send must still be nagged, got {} LLM calls",
+ captured.len()
+ );
+ assert_eq!(reply_nag_count(&captured[2]), 1);
+ h.shutdown().await;
+}
+
+/// The shared `_Stop` rejection budget is the outer cap: at 1 the guard gets
+/// one reminder instead of two. Documented degradation, not a bug.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_bounded_by_stop_rejection_budget() {
+ let llm = spawn_capturing_llm(vec![
+ openai_text("silent-1"),
+ openai_text("silent-2"),
+ openai_text("must-not-be-requested"),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_REQUIRE_REPLY", "1"),
+ ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"),
+ ],
+ )
+ .await;
+ let sid = init_session(&mut h, json!([])).await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 2,
+ "budget 1 must allow exactly one reminder, got {} LLM calls",
+ captured.len()
+ );
+ assert_eq!(reply_nag_count(&captured[1]), 1);
+ h.shutdown().await;
+}
+
+/// Budget 0 disables every objection at the gate, including this one.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_off_when_stop_budget_is_zero() {
+ let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_REQUIRE_REPLY", "1"),
+ ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "0"),
+ ],
+ )
+ .await;
+ let sid = init_session(&mut h, json!([])).await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 1,
+ "budget 0 must disable the guard, got {} LLM calls",
+ captured.len()
+ );
+ h.shutdown().await;
+}
+
+/// The two axes are independent inside one shared budget: a round carrying
+/// both a `_Stop` hook objection and a reminder costs one rejection and
+/// delivers both texts, and once the reminders are spent the hook objection
+/// continues alone.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_combines_with_stop_hook_objection() {
+ // The hook objects on its first 3 calls, then clears. Reminders stop
+ // after 2, so round 3 must carry the hook text and no new reminder.
+ let llm = spawn_capturing_llm(vec![
+ openai_text("silent-1"),
+ openai_text("silent-2"),
+ openai_text("silent-3"),
+ openai_text("silent-4"),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_REQUIRE_REPLY", "1"),
+ ("MCP_HOOK_SERVERS", "fake"),
+ ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "10"),
+ ],
+ )
+ .await;
+ let sid = init_session_with_fake_mcp(
+ &mut h,
+ &[
+ ("FAKE_MCP_TOOL_COUNT", "1"),
+ ("FAKE_MCP_STOP_HOOK", "1"),
+ ("FAKE_MCP_STOP_TEXT", "you have open todos"),
+ ("FAKE_MCP_STOP_COUNT", "3"),
+ ],
+ )
+ .await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 4,
+ "expected 3 objecting rounds then a clear end, got {}",
+ captured.len()
+ );
+
+ let hook_objections = |req: &Value| -> usize {
+ req["messages"]
+ .as_array()
+ .map(|msgs| {
+ msgs.iter()
+ .filter(|m| {
+ m["content"]
+ .as_str()
+ .unwrap_or("")
+ .contains("you have open todos")
+ })
+ .count()
+ })
+ .unwrap_or(0)
+ };
+
+ // Round 2 carries one of each — a single rejection bought both texts.
+ assert_eq!(reply_nag_count(&captured[1]), 1);
+ assert_eq!(hook_objections(&captured[1]), 1);
+ // Round 4: the hook objected three times, the guard only twice.
+ assert_eq!(reply_nag_count(&captured[3]), 2);
+ assert_eq!(hook_objections(&captured[3]), 3);
+ h.shutdown().await;
+}
+
+/// An unparseable toggle is a startup error, not a silent default. `parse_env`
+/// is generic over `FromStr`, so this also pins the numeric type: a `bool`
+/// field would have rejected the documented `1`.
+#[test]
+fn reply_guard_rejects_unparseable_toggle() {
+ let out = std::process::Command::new(env!("CARGO_BIN_EXE_buzz-agent"))
+ .env("BUZZ_AGENT_PROVIDER", "openai")
+ .env("OPENAI_COMPAT_API_KEY", "test")
+ .env("OPENAI_COMPAT_MODEL", "fake-model")
+ .env("BUZZ_AGENT_REQUIRE_REPLY", "true")
+ .stdin(Stdio::null())
+ .output()
+ .expect("run buzz-agent");
+ assert!(
+ !out.status.success(),
+ "expected a config error exit, got {:?}",
+ out.status
+ );
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(
+ stderr.contains("BUZZ_AGENT_REQUIRE_REPLY"),
+ "expected the offending key in the error, got: {stderr}"
+ );
+}
diff --git a/crates/buzz-voice/Cargo.toml b/crates/buzz-voice/Cargo.toml
new file mode 100644
index 0000000000..beff5b4a54
--- /dev/null
+++ b/crates/buzz-voice/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "buzz-voice"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+description = "Reusable local voice primitives for Buzz"
+
+[dependencies]
+atomic-write-file = "0.3"
+hex = { workspace = true }
+ort = { version = "=2.0.0-rc.12", default-features = false, features = ["api-24", "ndarray", "std"] }
+ort-sys = { version = "=2.0.0-rc.12", features = ["disable-linking"] }
+rand = "0.10"
+sentencepiece-model = "0.1"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+sha2 = { workspace = true }
+sherpa-onnx = "1.12"
+symphonia = { version = "0.5", default-features = false, features = ["aac", "aiff", "alac", "flac", "isomp4", "mp3", "ogg", "pcm", "vorbis", "wav"] }
+tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] }
+
+[dev-dependencies]
+tempfile = "3"
diff --git a/crates/buzz-voice/src/imported.rs b/crates/buzz-voice/src/imported.rs
new file mode 100644
index 0000000000..6f0ea71cad
--- /dev/null
+++ b/crates/buzz-voice/src/imported.rs
@@ -0,0 +1,730 @@
+//! Device-local Pocket reference voice validation, canonicalization, and storage.
+
+use std::{
+ fs,
+ io::Write,
+ path::{Path, PathBuf},
+};
+
+use atomic_write_file::AtomicWriteFile;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use symphonia::core::{
+ audio::SampleBuffer, codecs::DecoderOptions, errors::Error as SymphoniaError,
+ formats::FormatOptions, io::MediaSourceStream, meta::MetadataOptions, probe::Hint,
+};
+
+const MAX_SOURCE_BYTES: u64 = 25 * 1024 * 1024;
+const MIN_SAMPLE_RATE: u32 = 8_000;
+const MAX_SAMPLE_RATE: u32 = 96_000;
+const MIN_DURATION_SECONDS: f64 = 2.0;
+const MAX_DURATION_SECONDS: f64 = 30.0;
+pub const CANONICAL_SAMPLE_RATE: u32 = 32_000;
+const REGISTRY_VERSION: u32 = 1;
+const REGISTRY_FILE: &str = "registry.json";
+
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub struct ImportedVoice {
+ pub key: String,
+ pub display_name: String,
+ pub content_hash: String,
+ pub file_name: String,
+}
+
+#[derive(Default, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ImportedVoiceRegistry {
+ version: u32,
+ voices: Vec,
+}
+
+#[derive(Clone, Debug)]
+pub struct PocketVoiceLibrary {
+ root: PathBuf,
+}
+
+impl PocketVoiceLibrary {
+ pub fn new(root: impl Into) -> Self {
+ Self { root: root.into() }
+ }
+
+ pub fn root(&self) -> &Path {
+ &self.root
+ }
+
+ fn registry_path(&self) -> PathBuf {
+ self.root.join(REGISTRY_FILE)
+ }
+
+ pub fn load(&self) -> Result, String> {
+ let path = self.registry_path();
+ if !path.exists() {
+ return Ok(Vec::new());
+ }
+ let bytes =
+ fs::read(&path).map_err(|error| format!("could not read imported voices: {error}"))?;
+ let registry: ImportedVoiceRegistry = serde_json::from_slice(&bytes)
+ .map_err(|error| format!("imported voice registry is invalid: {error}"))?;
+ if registry.version > REGISTRY_VERSION {
+ return Err(format!(
+ "imported voice registry version {} is newer than this Buzz build supports",
+ registry.version
+ ));
+ }
+ Ok(registry
+ .voices
+ .into_iter()
+ .filter(valid_identity)
+ .filter(|voice| self.resolve_file(voice).is_ok())
+ .collect())
+ }
+
+ fn save(&self, voices: &[ImportedVoice]) -> Result<(), String> {
+ ensure_storage_dir(&self.root)?;
+ let payload = serde_json::to_vec_pretty(&ImportedVoiceRegistry {
+ version: REGISTRY_VERSION,
+ voices: voices.to_vec(),
+ })
+ .map_err(|error| format!("could not encode imported voice registry: {error}"))?;
+ atomic_write_restricted(&self.registry_path(), &payload)
+ .map_err(|error| format!("could not save imported voice registry: {error}"))
+ }
+
+ pub fn resolve_file(&self, voice: &ImportedVoice) -> Result {
+ if !valid_identity(voice) {
+ return Err("Imported voice registry contains an invalid file identity".to_string());
+ }
+ let path = self.root.join(&voice.file_name);
+ if !is_regular_file_without_symlink(&path) {
+ return Err(format!("Imported voice {} is missing", voice.display_name));
+ }
+ let bytes =
+ fs::read(&path).map_err(|error| format!("could not verify imported voice: {error}"))?;
+ if hex::encode(Sha256::digest(bytes)) != voice.content_hash {
+ return Err(format!(
+ "Imported voice {} does not match its content identity",
+ voice.display_name
+ ));
+ }
+ Ok(path)
+ }
+
+ pub fn find(&self, key: &str) -> Result, String> {
+ Ok(self.load()?.into_iter().find(|voice| voice.key == key))
+ }
+
+ pub fn import_path(&self, source: &Path) -> Result {
+ let metadata = fs::metadata(source)
+ .map_err(|error| format!("could not inspect selected audio: {error}"))?;
+ if metadata.len() > MAX_SOURCE_BYTES {
+ return Err("Voice audio must be 25 MB or smaller".to_string());
+ }
+ let extension = source
+ .extension()
+ .and_then(|extension| extension.to_str())
+ .map(str::to_ascii_lowercase)
+ .ok_or_else(|| "Voice audio must have a supported file extension".to_string())?;
+ let samples = if extension == "wav" {
+ let source_bytes = fs::read(source)
+ .map_err(|error| format!("could not read selected audio: {error}"))?;
+ decode_wav(&source_bytes)?
+ } else {
+ decode_media(source, &extension)?
+ };
+ let canonical_samples = resample_linear(&samples.samples, samples.sample_rate);
+ let canonical = encode_pcm16_wav(&canonical_samples, CANONICAL_SAMPLE_RATE);
+ let hash = hex::encode(Sha256::digest(&canonical));
+ let key = format!("pocket:imported:{hash}");
+ let file_name = format!("{hash}.wav");
+ let display_name = source
+ .file_stem()
+ .and_then(|name| name.to_str())
+ .map(str::trim)
+ .filter(|name| !name.is_empty())
+ .unwrap_or("Imported voice")
+ .chars()
+ .take(80)
+ .collect::();
+
+ ensure_storage_dir(&self.root)?;
+ let file_path = self.root.join(&file_name);
+ let file_created = !file_path.exists();
+ if file_created {
+ atomic_write_restricted(&file_path, &canonical)
+ .map_err(|error| format!("could not save imported voice audio: {error}"))?;
+ } else {
+ if !is_regular_file_without_symlink(&file_path) {
+ return Err("Imported voice storage contains an unsafe file entry".to_string());
+ }
+ let existing = fs::read(&file_path)
+ .map_err(|error| format!("could not verify imported voice audio: {error}"))?;
+ if hex::encode(Sha256::digest(&existing)) != hash {
+ return Err("Imported voice storage contains mismatched audio data".to_string());
+ }
+ }
+
+ let mut imported = ImportedVoice {
+ key,
+ display_name,
+ content_hash: hash,
+ file_name,
+ };
+ let mut voices = self.load()?;
+ if let Some(existing) = voices
+ .iter()
+ .find(|voice| voice.content_hash == imported.content_hash)
+ {
+ imported = existing.clone();
+ } else {
+ voices.push(imported.clone());
+ }
+ if let Err(error) = self.save(&voices) {
+ if file_created {
+ let _ = fs::remove_file(&file_path);
+ }
+ return Err(error);
+ }
+ Ok(imported)
+ }
+
+ pub fn delete(&self, key: &str) -> Result<(), String> {
+ let mut voices = self.load()?;
+ let index = voices
+ .iter()
+ .position(|voice| voice.key == key)
+ .ok_or_else(|| format!("Unknown imported voice: {key}"))?;
+ let previous_voices = voices.clone();
+ let removed = voices.remove(index);
+ self.save(&voices)?;
+ let path = self.root.join(removed.file_name);
+ match fs::remove_file(path) {
+ Ok(()) => Ok(()),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(error) => {
+ self.save(&previous_voices).map_err(|rollback_error| {
+ format!(
+ "Imported voice audio could not be deleted ({error}), and its registry \
+ entry could not be restored ({rollback_error})"
+ )
+ })?;
+ Err(format!(
+ "Imported voice audio could not be deleted: {error}"
+ ))
+ }
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct PcmStats {
+ pub sample_count: usize,
+ pub sample_rate: u32,
+ pub duration_seconds: f64,
+ pub peak: f32,
+ pub rms: f32,
+ pub non_silent_samples: usize,
+}
+
+impl PcmStats {
+ pub fn analyze(samples: &[f32], sample_rate: u32) -> Self {
+ let peak = samples
+ .iter()
+ .filter(|sample| sample.is_finite())
+ .fold(0.0_f32, |peak, sample| peak.max(sample.abs()));
+ let square_sum = samples
+ .iter()
+ .filter(|sample| sample.is_finite())
+ .map(|sample| sample * sample)
+ .sum::();
+ let rms = if samples.is_empty() {
+ 0.0
+ } else {
+ (square_sum / samples.len() as f32).sqrt()
+ };
+ Self {
+ sample_count: samples.len(),
+ sample_rate,
+ duration_seconds: if sample_rate == 0 {
+ 0.0
+ } else {
+ samples.len() as f64 / f64::from(sample_rate)
+ },
+ peak,
+ rms,
+ non_silent_samples: samples
+ .iter()
+ .filter(|sample| sample.is_finite() && sample.abs() >= 0.001)
+ .count(),
+ }
+ }
+
+ pub fn is_non_silent(self) -> bool {
+ self.peak >= 0.001 && self.rms >= 0.0001 && self.non_silent_samples > 0
+ }
+}
+
+pub fn write_pcm16_wav(path: &Path, samples: &[f32], sample_rate: u32) -> Result<(), String> {
+ let bytes = encode_pcm16_wav(samples, sample_rate);
+ fs::write(path, bytes).map_err(|error| format!("could not write PCM evidence: {error}"))
+}
+
+fn ensure_storage_dir(path: &Path) -> Result<(), String> {
+ fs::create_dir_all(path)
+ .map_err(|error| format!("could not create local voice storage: {error}"))?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(path, fs::Permissions::from_mode(0o700))
+ .map_err(|error| format!("could not restrict local voice storage: {error}"))?;
+ }
+ Ok(())
+}
+
+fn atomic_write_restricted(path: &Path, payload: &[u8]) -> Result<(), String> {
+ let resolved = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
+ let mut file = AtomicWriteFile::open(&resolved)
+ .map_err(|error| format!("open {} for atomic write: {error}", resolved.display()))?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ file.set_permissions(fs::Permissions::from_mode(0o600))
+ .map_err(|error| format!("set {} permissions: {error}", resolved.display()))?;
+ }
+ file.write_all(payload)
+ .map_err(|error| format!("write {}: {error}", resolved.display()))?;
+ file.commit()
+ .map_err(|error| format!("commit {}: {error}", resolved.display()))
+}
+
+fn valid_hash(hash: &str) -> bool {
+ hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
+}
+
+fn valid_identity(voice: &ImportedVoice) -> bool {
+ valid_hash(&voice.content_hash)
+ && voice.key == format!("pocket:imported:{}", voice.content_hash)
+ && voice.file_name == format!("{}.wav", voice.content_hash)
+}
+
+fn is_regular_file_without_symlink(path: &Path) -> bool {
+ fs::symlink_metadata(path)
+ .is_ok_and(|metadata| metadata.file_type().is_file() && !metadata.file_type().is_symlink())
+}
+
+#[derive(Debug)]
+struct DecodedAudio {
+ sample_rate: u32,
+ samples: Vec,
+}
+
+fn decode_wav(bytes: &[u8]) -> Result {
+ if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
+ return Err("Selected file is not a valid RIFF/WAVE file".to_string());
+ }
+ let mut offset = 12usize;
+ let mut format = None;
+ let mut data = None;
+ while offset.checked_add(8).is_some_and(|end| end <= bytes.len()) {
+ let id = &bytes[offset..offset + 4];
+ let size =
+ u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap_or([0; 4])) as usize;
+ let start = offset + 8;
+ let end = start.checked_add(size).ok_or("WAV chunk size overflow")?;
+ if end > bytes.len() {
+ return Err("Selected WAV contains a truncated chunk".to_string());
+ }
+ if id == b"fmt " {
+ format = Some(&bytes[start..end]);
+ } else if id == b"data" {
+ data = Some(&bytes[start..end]);
+ }
+ offset = end + (size & 1);
+ }
+ let format = format.ok_or("Selected WAV has no format chunk")?;
+ let data = data.ok_or("Selected WAV has no audio data")?;
+ if format.len() < 16 {
+ return Err("Selected WAV has an invalid format chunk".to_string());
+ }
+ let encoding = u16::from_le_bytes(format[0..2].try_into().unwrap_or([0; 2]));
+ let encoding = if encoding == 0xfffe && format.len() >= 40 {
+ u16::from_le_bytes(format[24..26].try_into().unwrap_or([0; 2]))
+ } else {
+ encoding
+ };
+ let channels = u16::from_le_bytes(format[2..4].try_into().unwrap_or([0; 2]));
+ let sample_rate = u32::from_le_bytes(format[4..8].try_into().unwrap_or([0; 4]));
+ let block_align = u16::from_le_bytes(format[12..14].try_into().unwrap_or([0; 2])) as usize;
+ let bits = u16::from_le_bytes(format[14..16].try_into().unwrap_or([0; 2]));
+ if channels == 0 || channels > 8 {
+ return Err("Voice WAV must contain between 1 and 8 channels".to_string());
+ }
+ if !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&sample_rate) {
+ return Err("Voice WAV sample rate must be between 8 and 96 kHz".to_string());
+ }
+ let bytes_per_sample = usize::from(bits.div_ceil(8));
+ if block_align != bytes_per_sample * usize::from(channels)
+ || block_align == 0
+ || data.len() % block_align != 0
+ {
+ return Err("Voice WAV has invalid sample alignment".to_string());
+ }
+ if !matches!((encoding, bits), (1, 8 | 16 | 24 | 32) | (3, 32)) {
+ return Err("Voice WAV must contain PCM or 32-bit float audio".to_string());
+ }
+ let frames = data.len() / block_align;
+ let duration = frames as f64 / f64::from(sample_rate);
+ if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&duration) {
+ return Err("Voice WAV must be between 2 and 30 seconds long".to_string());
+ }
+
+ let mut samples = Vec::with_capacity(frames);
+ for frame in data.chunks_exact(block_align) {
+ let mut mono = 0.0_f32;
+ for chunk in frame.chunks_exact(bytes_per_sample) {
+ let sample = match (encoding, bits) {
+ (1, 8) => (f32::from(chunk[0]) - 128.0) / 128.0,
+ (1, 16) => f32::from(i16::from_le_bytes([chunk[0], chunk[1]])) / 32768.0,
+ (1, 24) => {
+ let raw = i32::from_le_bytes([
+ chunk[0],
+ chunk[1],
+ chunk[2],
+ if chunk[2] & 0x80 == 0 { 0 } else { 0xff },
+ ]);
+ raw as f32 / 8_388_608.0
+ }
+ (1, 32) => {
+ i32::from_le_bytes(chunk.try_into().map_err(|_| "invalid PCM sample")?) as f32
+ / 2_147_483_648.0
+ }
+ (3, 32) => f32::from_le_bytes(
+ chunk
+ .try_into()
+ .map_err(|_| "invalid floating-point sample")?,
+ ),
+ _ => unreachable!(),
+ };
+ if !sample.is_finite() {
+ return Err("Voice WAV contains non-finite samples".to_string());
+ }
+ mono += sample;
+ }
+ samples.push((mono / f32::from(channels)).clamp(-1.0, 1.0));
+ }
+ let stats = PcmStats::analyze(&samples, sample_rate);
+ if !stats.is_non_silent() {
+ return Err("Voice WAV is silent or too quiet to clone".to_string());
+ }
+ Ok(DecodedAudio {
+ sample_rate,
+ samples,
+ })
+}
+
+fn decode_media(source: &Path, extension: &str) -> Result {
+ let supported = ["m4a", "mp3", "flac", "ogg", "oga", "aif", "aiff"];
+ if !supported.contains(&extension) {
+ return Err(format!(
+ "Unsupported voice audio format .{extension}. Choose WAV, M4A, MP3, FLAC, OGG, or AIFF"
+ ));
+ }
+
+ let file = fs::File::open(source)
+ .map_err(|error| format!("could not read selected audio: {error}"))?;
+ let media = MediaSourceStream::new(Box::new(file), Default::default());
+ let mut hint = Hint::new();
+ hint.with_extension(extension);
+ let probed = symphonia::default::get_probe()
+ .format(
+ &hint,
+ media,
+ &FormatOptions::default(),
+ &MetadataOptions::default(),
+ )
+ .map_err(|error| format!("could not recognize selected audio: {error}"))?;
+ let mut format = probed.format;
+ let track = format
+ .default_track()
+ .ok_or_else(|| "Selected audio has no decodable track".to_string())?;
+ let track_id = track.id;
+ let mut decoder = symphonia::default::get_codecs()
+ .make(&track.codec_params, &DecoderOptions::default())
+ .map_err(|error| format!("could not initialize audio decoder: {error}"))?;
+ let mut sample_rate = None;
+ let mut samples = Vec::new();
+
+ loop {
+ let packet = match format.next_packet() {
+ Ok(packet) => packet,
+ Err(SymphoniaError::ResetRequired) => {
+ return Err("Selected audio changes format mid-stream".to_string());
+ }
+ Err(SymphoniaError::IoError(error))
+ if error.kind() == std::io::ErrorKind::UnexpectedEof =>
+ {
+ break;
+ }
+ Err(error) => return Err(format!("could not read selected audio: {error}")),
+ };
+ if packet.track_id() != track_id {
+ continue;
+ }
+ let decoded = match decoder.decode(&packet) {
+ Ok(decoded) => decoded,
+ Err(SymphoniaError::DecodeError(_)) => continue,
+ Err(error) => return Err(format!("could not decode selected audio: {error}")),
+ };
+ let spec = *decoded.spec();
+ if !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&spec.rate) {
+ return Err("Voice audio sample rate must be between 8 and 96 kHz".to_string());
+ }
+ if sample_rate.is_some_and(|rate| rate != spec.rate) {
+ return Err("Selected audio changes sample rate mid-stream".to_string());
+ }
+ sample_rate = Some(spec.rate);
+ let channels = spec.channels.count();
+ if channels == 0 || channels > 8 {
+ return Err("Voice audio must contain between 1 and 8 channels".to_string());
+ }
+ let mut buffer = SampleBuffer::::new(decoded.capacity() as u64, spec);
+ buffer.copy_interleaved_ref(decoded);
+ for frame in buffer.samples().chunks_exact(channels) {
+ let mono = frame.iter().copied().sum::() / channels as f32;
+ if !mono.is_finite() {
+ return Err("Voice audio contains non-finite samples".to_string());
+ }
+ samples.push(mono.clamp(-1.0, 1.0));
+ }
+ if samples.len() as f64 > MAX_DURATION_SECONDS * f64::from(spec.rate) {
+ return Err("Voice audio must be between 2 and 30 seconds long".to_string());
+ }
+ }
+
+ let sample_rate =
+ sample_rate.ok_or_else(|| "Selected audio contains no samples".to_string())?;
+ validate_decoded_audio(&samples, sample_rate)?;
+ Ok(DecodedAudio {
+ sample_rate,
+ samples,
+ })
+}
+
+fn validate_decoded_audio(samples: &[f32], sample_rate: u32) -> Result<(), String> {
+ let stats = PcmStats::analyze(samples, sample_rate);
+ if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&stats.duration_seconds) {
+ return Err("Voice audio must be between 2 and 30 seconds long".to_string());
+ }
+ if !stats.is_non_silent() {
+ return Err("Voice audio is silent or too quiet to clone".to_string());
+ }
+ Ok(())
+}
+
+fn resample_linear(samples: &[f32], source_rate: u32) -> Vec {
+ if source_rate == CANONICAL_SAMPLE_RATE {
+ return samples.to_vec();
+ }
+ let output_len = ((samples.len() as u64 * u64::from(CANONICAL_SAMPLE_RATE)
+ + u64::from(source_rate) / 2)
+ / u64::from(source_rate)) as usize;
+ (0..output_len)
+ .map(|index| {
+ let source = index as f64 * f64::from(source_rate) / f64::from(CANONICAL_SAMPLE_RATE);
+ let left = source.floor() as usize;
+ let fraction = (source - left as f64) as f32;
+ let a = samples[left.min(samples.len() - 1)];
+ let b = samples[(left + 1).min(samples.len() - 1)];
+ a + (b - a) * fraction
+ })
+ .collect()
+}
+
+fn encode_pcm16_wav(samples: &[f32], sample_rate: u32) -> Vec {
+ let data_len = (samples.len() * 2) as u32;
+ let mut bytes = Vec::with_capacity(44 + data_len as usize);
+ bytes.extend_from_slice(b"RIFF");
+ bytes.extend_from_slice(&(36 + data_len).to_le_bytes());
+ bytes.extend_from_slice(b"WAVEfmt ");
+ bytes.extend_from_slice(&16_u32.to_le_bytes());
+ bytes.extend_from_slice(&1_u16.to_le_bytes());
+ bytes.extend_from_slice(&1_u16.to_le_bytes());
+ bytes.extend_from_slice(&sample_rate.to_le_bytes());
+ bytes.extend_from_slice(&(sample_rate * 2).to_le_bytes());
+ bytes.extend_from_slice(&2_u16.to_le_bytes());
+ bytes.extend_from_slice(&16_u16.to_le_bytes());
+ bytes.extend_from_slice(b"data");
+ bytes.extend_from_slice(&data_len.to_le_bytes());
+ for sample in samples {
+ let value = (sample.clamp(-1.0, 1.0) * f32::from(i16::MAX)).round() as i16;
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+ bytes
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn fixture(sample_rate: u32, seconds: usize, amplitude: f32) -> Vec {
+ let samples = (0..sample_rate as usize * seconds)
+ .map(|index| {
+ amplitude
+ * (std::f32::consts::TAU * 220.0 * index as f32 / sample_rate as f32).sin()
+ })
+ .collect::>();
+ encode_pcm16_wav(&samples, sample_rate)
+ }
+
+ fn stereo_fixture(sample_rate: u32, seconds: usize, amplitude: f32) -> Vec {
+ let mono = fixture(sample_rate, seconds, amplitude);
+ let mono_data = &mono[44..];
+ let mut stereo_data = Vec::with_capacity(mono_data.len() * 2);
+ for sample in mono_data.chunks_exact(2) {
+ stereo_data.extend_from_slice(sample);
+ stereo_data.extend_from_slice(sample);
+ }
+ let mut stereo = mono[..44].to_vec();
+ stereo[4..8].copy_from_slice(&(36 + stereo_data.len() as u32).to_le_bytes());
+ stereo[22..24].copy_from_slice(&2_u16.to_le_bytes());
+ stereo[28..32].copy_from_slice(&(sample_rate * 4).to_le_bytes());
+ stereo[32..34].copy_from_slice(&4_u16.to_le_bytes());
+ stereo[40..44].copy_from_slice(&(stereo_data.len() as u32).to_le_bytes());
+ stereo.extend_from_slice(&stereo_data);
+ stereo
+ }
+
+ #[test]
+ fn imports_persists_reloads_and_deletes_canonical_voice() {
+ let temp = tempfile::tempdir().expect("temp voice workspace");
+ let source = temp.path().join("My voice.wav");
+ fs::write(&source, fixture(44_100, 2, 0.5)).expect("write source");
+ let library = PocketVoiceLibrary::new(temp.path().join("library"));
+
+ let imported = library.import_path(&source).expect("import voice");
+ assert!(imported.key.starts_with("pocket:imported:"));
+ assert_eq!(imported.display_name, "My voice");
+
+ let relaunched = PocketVoiceLibrary::new(library.root());
+ assert_eq!(
+ relaunched.load().expect("reload registry"),
+ vec![imported.clone()]
+ );
+ let stored = relaunched
+ .resolve_file(&imported)
+ .expect("resolve stored voice");
+ let decoded = decode_wav(&fs::read(&stored).expect("read stored voice"))
+ .expect("decode canonical voice");
+ assert_eq!(decoded.sample_rate, CANONICAL_SAMPLE_RATE);
+ assert_eq!(decoded.samples.len(), CANONICAL_SAMPLE_RATE as usize * 2);
+
+ assert_eq!(
+ relaunched.import_path(&source).expect("idempotent import"),
+ imported
+ );
+ assert_eq!(relaunched.load().expect("deduplicated registry").len(), 1);
+
+ relaunched.delete(&imported.key).expect("delete voice");
+ assert!(relaunched.load().expect("empty registry").is_empty());
+ assert!(!stored.exists());
+ }
+
+ #[test]
+ fn common_stereo_audio_is_downmixed_to_canonical_mono() {
+ let temp = tempfile::tempdir().expect("temp voice workspace");
+ let source = temp.path().join("stereo.wav");
+ fs::write(&source, stereo_fixture(44_100, 2, 0.5)).expect("write stereo");
+ let library = PocketVoiceLibrary::new(temp.path().join("library"));
+
+ let imported = library.import_path(&source).expect("import stereo");
+ let stored = library
+ .resolve_file(&imported)
+ .expect("resolve stored voice");
+ let decoded = decode_wav(&fs::read(stored).expect("read stored voice"))
+ .expect("decode canonical voice");
+ assert_eq!(decoded.sample_rate, CANONICAL_SAMPLE_RATE);
+ assert_eq!(decoded.samples.len(), CANONICAL_SAMPLE_RATE as usize * 2);
+ }
+
+ #[test]
+ #[ignore = "requires BUZZ_VOICE_IMPORT_TEST_DIR with common-format fixtures"]
+ fn imports_common_audio_format_fixtures() {
+ let fixtures =
+ PathBuf::from(std::env::var("BUZZ_VOICE_IMPORT_TEST_DIR").expect("fixture directory"));
+ let temp = tempfile::tempdir().expect("temp voice workspace");
+ let library = PocketVoiceLibrary::new(temp.path().join("library"));
+
+ for file_name in [
+ "voice.wav",
+ "voice.m4a",
+ "voice.mp3",
+ "voice.flac",
+ "voice.ogg",
+ "voice.aiff",
+ ] {
+ let imported = library
+ .import_path(&fixtures.join(file_name))
+ .unwrap_or_else(|error| panic!("import {file_name}: {error}"));
+ let stored = library
+ .resolve_file(&imported)
+ .unwrap_or_else(|error| panic!("resolve {file_name}: {error}"));
+ let decoded = decode_wav(&fs::read(stored).expect("read canonical voice"))
+ .expect("decode canonical voice");
+ assert_eq!(decoded.sample_rate, CANONICAL_SAMPLE_RATE);
+ assert!(decoded.samples.len() >= CANONICAL_SAMPLE_RATE as usize * 2);
+ }
+ }
+
+ #[test]
+ fn invalid_unsupported_and_silent_files_do_not_mutate_registry() {
+ let temp = tempfile::tempdir().expect("temp voice workspace");
+ let library = PocketVoiceLibrary::new(temp.path().join("library"));
+
+ let garbage = temp.path().join("garbage.wav");
+ fs::write(&garbage, b"not a wave").expect("write garbage");
+ assert!(library
+ .import_path(&garbage)
+ .expect_err("garbage rejected")
+ .contains("RIFF/WAVE"));
+
+ let silent = temp.path().join("silent.wav");
+ fs::write(&silent, fixture(32_000, 2, 0.0)).expect("write silence");
+ assert!(library
+ .import_path(&silent)
+ .expect_err("silence rejected")
+ .contains("silent"));
+
+ let unsupported_container = temp.path().join("voice.txt");
+ fs::write(&unsupported_container, b"not audio").expect("write unsupported container");
+ assert!(library
+ .import_path(&unsupported_container)
+ .expect_err("container rejected")
+ .contains("Unsupported voice audio format"));
+
+ let mut unsupported = fixture(32_000, 2, 0.5);
+ unsupported[20..22].copy_from_slice(&6_u16.to_le_bytes());
+ let unsupported_path = temp.path().join("unsupported.wav");
+ fs::write(&unsupported_path, unsupported).expect("write unsupported");
+ assert!(library
+ .import_path(&unsupported_path)
+ .expect_err("unsupported rejected")
+ .contains("PCM or 32-bit float"));
+
+ assert!(library.load().expect("unchanged registry").is_empty());
+ }
+
+ #[test]
+ fn pcm_analysis_distinguishes_signal_from_silence() {
+ let signal = (0..24_000)
+ .map(|index| (std::f32::consts::TAU * 440.0 * index as f32 / 24_000.0).sin() * 0.5)
+ .collect::>();
+ let signal_stats = PcmStats::analyze(&signal, 24_000);
+ assert!(signal_stats.is_non_silent());
+ assert_eq!(signal_stats.duration_seconds, 1.0);
+ assert!(signal_stats.peak > 0.49);
+ assert!(signal_stats.rms > 0.3);
+
+ let silence = vec![0.0; 24_000];
+ assert!(!PcmStats::analyze(&silence, 24_000).is_non_silent());
+ }
+}
diff --git a/crates/buzz-voice/src/lib.rs b/crates/buzz-voice/src/lib.rs
new file mode 100644
index 0000000000..e4b4ebfed3
--- /dev/null
+++ b/crates/buzz-voice/src/lib.rs
@@ -0,0 +1,23 @@
+//! Reusable local voice primitives for Buzz.
+
+pub mod imported;
+pub mod pocket;
+
+pub use pocket::{
+ april_model_info, load_text_to_speech, load_voice_style, PocketModelInfo, PocketTts,
+ VoiceStyle, DEFAULT_VOICE, SAMPLE_RATE, VOICE_FILE_EXT,
+};
+
+/// One immutable artifact required by the April Pocket bundle.
+///
+/// `filename` is the bundle-relative file name, `sha256` pins its contents,
+/// `size_bytes` supports download progress and validation, and `quantized`
+/// identifies the INT8 components.
+pub type PocketModelArtifact = pocket::PocketModelArtifact;
+
+/// Language bundle selected from the pinned export.
+pub const APRIL_BUNDLE_ID: &str = pocket::APRIL_BUNDLE_ID;
+/// Pinned upstream export repository.
+pub const APRIL_MODEL_ID: &str = pocket::APRIL_MODEL_ID;
+/// Pinned revision containing the April bundle.
+pub const APRIL_MODEL_REVISION: &str = pocket::APRIL_MODEL_REVISION;
diff --git a/crates/buzz-voice/src/pocket.rs b/crates/buzz-voice/src/pocket.rs
new file mode 100644
index 0000000000..0c6174a8dc
--- /dev/null
+++ b/crates/buzz-voice/src/pocket.rs
@@ -0,0 +1,167 @@
+//! April 2026 Pocket TTS engine for Buzz Desktop.
+//!
+//! The `english_2026-04` bundle uses SentencePiece tokenization, a learned
+//! voice BOS embedding, recurrent FlowLM state, and stateful Mimi decoding.
+//! Buzz selects the upstream three-graph INT8 variant while retaining the
+//! full-precision Mimi encoder and text conditioner specified by that variant.
+//!
+//! ## Attribution
+//!
+//! - Pocket TTS and Mimi: Kyutai, CC-BY-4.0.
+//! - ONNX export: KevinAHM/pocket-tts-onnx, CC-BY-4.0.
+//! - Reference voice: Kyutai's Mary preset (VCTK p333), CC-BY-4.0.
+//!
+//! `huddle::models` writes the complete attribution beside the cached bytes.
+
+use std::path::{Path, PathBuf};
+use std::sync::Mutex;
+
+use sherpa_onnx::Wave;
+
+#[path = "pocket_april.rs"]
+mod pocket_april;
+#[path = "pocket_models.rs"]
+mod pocket_models;
+
+use pocket_april::{prepare_april_prompt, AprilPocketTts};
+pub use pocket_models::{
+ april_model_info, PocketModelArtifact, PocketModelInfo, APRIL_BUNDLE_ID, APRIL_MODEL_ID,
+ APRIL_MODEL_REVISION,
+};
+
+/// Pocket TTS emits 24 kHz mono PCM.
+pub const SAMPLE_RATE: u32 = 24_000;
+
+/// Bundled reference voice name without its extension.
+pub const DEFAULT_VOICE: &str = "reference_sample";
+
+/// Pocket voice files are reference WAVs.
+pub const VOICE_FILE_EXT: &str = "wav";
+
+const TTS_NUM_THREADS: usize = 1;
+
+/// Loaded reference voice samples and their original sample rate.
+#[derive(Debug, Clone)]
+pub struct VoiceStyle {
+ samples: Vec,
+ sample_rate: i32,
+}
+
+/// Load a Pocket reference voice WAV from disk.
+pub fn load_voice_style(path: &Path) -> Result {
+ let path_str = path
+ .to_str()
+ .ok_or_else(|| format!("voice path is not valid UTF-8: {}", path.display()))?;
+ let wave = Wave::read(path_str)
+ .ok_or_else(|| format!("could not read voice WAV at {}", path.display()))?;
+ let samples = wave.samples().to_vec();
+ if samples.is_empty() {
+ return Err(format!("voice WAV is empty: {}", path.display()));
+ }
+ Ok(VoiceStyle {
+ samples,
+ sample_rate: wave.sample_rate(),
+ })
+}
+
+/// Resident April INT8 Pocket TTS engine.
+pub struct PocketTts {
+ inner: Mutex,
+}
+
+/// Load Buzz Desktop's pinned April INT8 model.
+pub fn load_text_to_speech(model_dir: &str) -> Result {
+ let dir = PathBuf::from(model_dir);
+ for artifact in april_model_info().artifacts {
+ let path = dir.join(artifact.filename);
+ if !path.is_file() {
+ return Err(format!(
+ "incomplete Pocket TTS {} INT8 bundle: missing {}",
+ APRIL_BUNDLE_ID,
+ path.display()
+ ));
+ }
+ }
+ Ok(PocketTts {
+ inner: Mutex::new(AprilPocketTts::load(&dir, TTS_NUM_THREADS)?),
+ })
+}
+
+impl PocketTts {
+ /// Split text into synthesis units that satisfy the bundle's exact
+ /// 50-token input limit.
+ pub fn split_text_into_chunks(&self, text: &str) -> Result, String> {
+ let Some(prepared) = prepare_april_prompt(text) else {
+ return Ok(Vec::new());
+ };
+ self.inner
+ .lock()
+ .map_err(|_| "Pocket TTS engine lock poisoned".to_string())?
+ .split_prompt(&prepared)
+ }
+
+ /// Synthesize text with the supplied reference voice.
+ ///
+ /// Pocket detects language from text and this model uses one synthesis
+ /// step, so `_lang` and `_steps` intentionally do not affect output.
+ pub fn synth_chunk(
+ &self,
+ text: &str,
+ _lang: &str,
+ style: &VoiceStyle,
+ _steps: usize,
+ ) -> Result, String> {
+ let Some(prepared) = prepare_april_prompt(text) else {
+ return Ok(Vec::new());
+ };
+ let mut engine = self
+ .inner
+ .lock()
+ .map_err(|_| "Pocket TTS engine lock poisoned".to_string())?;
+ let chunks = engine.split_prompt(&prepared)?;
+ let mut samples = Vec::new();
+ for chunk in chunks {
+ let prepared = prepare_april_prompt(&chunk)
+ .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?;
+ samples.extend(engine.synth_chunk(&prepared, style)?);
+ }
+ Ok(samples)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn desktop_model_is_april_int8_only() {
+ let info = april_model_info();
+ assert_eq!(info.max_token_per_chunk, 50);
+ assert_eq!(info.sample_rate, SAMPLE_RATE);
+ assert!(info
+ .artifacts
+ .iter()
+ .any(|artifact| artifact.filename == "flow_lm_main_int8.onnx"));
+ assert!(!info
+ .artifacts
+ .iter()
+ .any(|artifact| artifact.filename == "flow_lm_main.onnx"));
+ }
+
+ #[test]
+ #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"]
+ fn production_api_emits_non_silent_april_int8_pcm() {
+ let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR")
+ .expect("set BUZZ_POCKET_TEST_MODEL_DIR to an April INT8 model directory");
+ let engine = load_text_to_speech(&dir).expect("load April INT8 engine");
+ let style = load_voice_style(&Path::new(&dir).join("reference_sample.wav"))
+ .expect("load reference voice");
+ let samples = engine
+ .synth_chunk("Bright birds begin beside the bay.", "en", &style, 1)
+ .expect("synthesize through the production API");
+
+ assert!(!samples.is_empty());
+ assert!(samples.iter().all(|sample| sample.is_finite()));
+ assert!(samples.iter().any(|sample| sample.abs() > 1.0e-6));
+ }
+}
diff --git a/crates/buzz-voice/src/pocket_april.rs b/crates/buzz-voice/src/pocket_april.rs
new file mode 100644
index 0000000000..43826df5c9
--- /dev/null
+++ b/crates/buzz-voice/src/pocket_april.rs
@@ -0,0 +1,940 @@
+//! Native ONNX loader for Pocket TTS `english_2026-04`.
+//!
+//! The bundle uses SentencePiece, prepends a learned BOS voice embedding, and
+//! describes recurrent state tensors in `bundle.json`. This module supplies
+//! that frontend and state loop while reusing the ONNX Runtime linked by the
+//! Desktop speech stack.
+
+use std::borrow::Cow;
+use std::f32::consts::TAU;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use ort::session::{Session, SessionInputValue};
+use ort::value::{DynValue, Tensor};
+use rand::{Rng, RngExt};
+use sentencepiece_model::SentencePieceModel;
+use serde::Deserialize;
+use sherpa_onnx::LinearResampler;
+use tokenizers::models::unigram::Unigram;
+use tokenizers::pre_tokenizers::metaspace::{Metaspace, PrependScheme};
+use tokenizers::Tokenizer;
+
+use super::VoiceStyle;
+
+const FILE_BUNDLE: &str = "bundle.json";
+const FILE_MIMI_ENCODER: &str = "mimi_encoder.onnx";
+const FILE_TEXT_CONDITIONER: &str = "text_conditioner.onnx";
+const FILE_FLOW_MAIN_INT8: &str = "flow_lm_main_int8.onnx";
+const FILE_FLOW_INT8: &str = "flow_lm_flow_int8.onnx";
+const FILE_MIMI_DECODER_INT8: &str = "mimi_decoder_int8.onnx";
+
+const MODEL_LANGUAGE: &str = "english_2026-04";
+const DEFAULT_TEMPERATURE: f32 = 0.7;
+const EOS_LOGIT_THRESHOLD: f32 = -4.0;
+const DECODER_CHUNK_FRAMES: usize = 12;
+const TOKENS_PER_SECOND_ESTIMATE: f32 = 3.0;
+const GENERATION_SECONDS_PADDING: f32 = 2.0;
+
+#[derive(Debug, Deserialize)]
+struct Bundle {
+ schema_version: u32,
+ language: String,
+ sample_rate: usize,
+ frame_rate: f32,
+ samples_per_frame: usize,
+ latent_dim: usize,
+ conditioning_dim: usize,
+ insert_bos_before_voice: bool,
+ pad_with_spaces_for_short_inputs: bool,
+ remove_semicolons: bool,
+ model_recommended_frames_after_eos: Option,
+ max_token_per_chunk: usize,
+ tokenizer_file: String,
+ bos_before_voice_file: String,
+ flow_lm_state_manifest: Vec,
+ mimi_state_manifest: Vec,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct StateSpec {
+ input_name: String,
+ output_name: String,
+ dtype: StateDtype,
+ shape: Vec,
+ fill: StateFill,
+}
+
+#[derive(Debug, Clone, Copy, Deserialize)]
+#[serde(rename_all = "lowercase")]
+enum StateDtype {
+ #[serde(rename = "float32")]
+ Float32,
+ #[serde(rename = "int64")]
+ Int64,
+ Bool,
+}
+
+#[derive(Debug, Clone, Copy, Deserialize)]
+#[serde(rename_all = "lowercase")]
+enum StateFill {
+ Empty,
+ Nan,
+ Ones,
+ Zeros,
+}
+
+struct StateValue {
+ spec: StateSpec,
+ value: DynValue,
+}
+
+struct CachedVoice {
+ samples_ptr: usize,
+ samples_len: usize,
+ sample_rate: i32,
+ embeddings: Vec,
+}
+
+pub(crate) struct AprilPocketTts {
+ bundle: Bundle,
+ tokenizer: Tokenizer,
+ bos_embedding: Vec,
+ mimi_encoder: Session,
+ text_conditioner: Session,
+ flow_main: Session,
+ flow: Session,
+ mimi_decoder: Session,
+ cached_voice: Option,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) struct AprilPreparedPrompt {
+ pub(crate) text: String,
+ pub(crate) frames_after_eos: usize,
+}
+
+pub(crate) fn prepare_april_prompt(input: &str) -> Option {
+ let trimmed = input.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+
+ let mut cleaned = String::with_capacity(trimmed.len());
+ let mut last_was_space = false;
+ for ch in trimmed.chars() {
+ if ch.is_whitespace() {
+ if !last_was_space {
+ cleaned.push(' ');
+ }
+ last_was_space = true;
+ } else {
+ cleaned.push(ch);
+ last_was_space = false;
+ }
+ }
+
+ let first = cleaned.chars().next().expect("cleaned non-empty above");
+ if first.is_lowercase() {
+ let upper: String = first.to_uppercase().collect();
+ let mut iter = cleaned.chars();
+ iter.next();
+ cleaned = upper + iter.as_str();
+ }
+
+ let last = cleaned
+ .chars()
+ .next_back()
+ .expect("cleaned non-empty above");
+ if last.is_alphanumeric() {
+ cleaned.push('.');
+ }
+
+ let word_count = cleaned.split_whitespace().count();
+ Some(AprilPreparedPrompt {
+ text: cleaned,
+ // Mirror the bundle's upstream heuristic: three generated frames plus
+ // two trailing frames for short prompts, one plus two otherwise.
+ frames_after_eos: if word_count <= 4 { 5 } else { 3 },
+ })
+}
+
+impl AprilPocketTts {
+ pub(crate) fn load(dir: &Path, num_threads: usize) -> Result {
+ if num_threads == 0 {
+ return Err("Pocket TTS num_threads must be at least 1".to_string());
+ }
+ let bundle_path = dir.join(FILE_BUNDLE);
+ let bundle: Bundle = serde_json::from_slice(
+ &fs::read(&bundle_path)
+ .map_err(|err| format!("read {}: {err}", bundle_path.display()))?,
+ )
+ .map_err(|err| format!("parse {}: {err}", bundle_path.display()))?;
+
+ if bundle.schema_version != 2 {
+ return Err(format!(
+ "unsupported Pocket TTS bundle schema {} in {}",
+ bundle.schema_version,
+ bundle_path.display()
+ ));
+ }
+ if bundle.language != MODEL_LANGUAGE {
+ return Err(format!(
+ "expected Pocket TTS language {MODEL_LANGUAGE}, got {}",
+ bundle.language
+ ));
+ }
+ if bundle.sample_rate != 24_000
+ || bundle.frame_rate != 12.5
+ || bundle.samples_per_frame != 1_920
+ || bundle.latent_dim != 32
+ || bundle.conditioning_dim != 1024
+ {
+ return Err(format!(
+ "unexpected Pocket TTS dimensions: sample_rate={}, frame_rate={}, samples_per_frame={}, latent_dim={}, conditioning_dim={}",
+ bundle.sample_rate,
+ bundle.frame_rate,
+ bundle.samples_per_frame,
+ bundle.latent_dim,
+ bundle.conditioning_dim
+ ));
+ }
+ if !bundle.insert_bos_before_voice {
+ return Err("April Pocket TTS bundle must insert BOS before voice".to_string());
+ }
+ if bundle.pad_with_spaces_for_short_inputs
+ || bundle.remove_semicolons
+ || bundle.model_recommended_frames_after_eos.is_some()
+ || bundle.max_token_per_chunk != 50
+ {
+ return Err("unsupported April Pocket TTS prompt-policy metadata".to_string());
+ }
+
+ let tokenizer_path = dir.join(&bundle.tokenizer_file);
+ let tokenizer = load_tokenizer(&tokenizer_path)?;
+ let bos_path = dir.join(&bundle.bos_before_voice_file);
+ let bos_embedding = read_npy_f32(&bos_path)?;
+ if bos_embedding.len() != bundle.conditioning_dim {
+ return Err(format!(
+ "{} has {} values; expected {}",
+ bos_path.display(),
+ bos_embedding.len(),
+ bundle.conditioning_dim
+ ));
+ }
+
+ let flow_main = FILE_FLOW_MAIN_INT8;
+ let flow = FILE_FLOW_INT8;
+ let mimi_decoder = FILE_MIMI_DECODER_INT8;
+
+ Ok(Self {
+ // The INT8 layout quantizes only the three generation graphs;
+ // voice encoding and text conditioning remain full precision.
+ mimi_encoder: load_session(dir.join(FILE_MIMI_ENCODER), num_threads)?,
+ text_conditioner: load_session(dir.join(FILE_TEXT_CONDITIONER), num_threads)?,
+ flow_main: load_session(dir.join(flow_main), num_threads)?,
+ flow: load_session(dir.join(flow), num_threads)?,
+ mimi_decoder: load_session(dir.join(mimi_decoder), num_threads)?,
+ bundle,
+ tokenizer,
+ bos_embedding,
+ cached_voice: None,
+ })
+ }
+
+ pub(crate) fn split_prompt(
+ &self,
+ prepared: &AprilPreparedPrompt,
+ ) -> Result, String> {
+ if self.token_count(&prepared.text)? <= self.bundle.max_token_per_chunk {
+ return Ok(vec![prepared.text.clone()]);
+ }
+
+ let mut chunks = Vec::new();
+ let mut current = String::new();
+ for word in prepared.text.split_whitespace() {
+ let candidate = if current.is_empty() {
+ word.to_string()
+ } else {
+ format!("{current} {word}")
+ };
+ if self.prepared_token_count(&candidate)? <= self.bundle.max_token_per_chunk {
+ current = candidate;
+ continue;
+ }
+ if !current.is_empty() {
+ chunks.push(std::mem::take(&mut current));
+ }
+
+ if self.prepared_token_count(word)? <= self.bundle.max_token_per_chunk {
+ current = word.to_string();
+ continue;
+ }
+
+ let mut fragment = String::new();
+ for ch in word.chars() {
+ let candidate = format!("{fragment}{ch}");
+ if !fragment.is_empty()
+ && self.prepared_token_count(&candidate)? > self.bundle.max_token_per_chunk
+ {
+ chunks.push(std::mem::take(&mut fragment));
+ }
+ fragment.push(ch);
+ }
+ current = fragment;
+ }
+ if !current.is_empty() {
+ chunks.push(current);
+ }
+
+ chunks
+ .into_iter()
+ .map(|text| {
+ let chunk = prepare_april_prompt(&text)
+ .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?;
+ let token_count = self.token_count(&chunk.text)?;
+ if token_count > self.bundle.max_token_per_chunk {
+ return Err(format!(
+ "Pocket TTS prompt chunk has {token_count} tokens; maximum is {}",
+ self.bundle.max_token_per_chunk
+ ));
+ }
+ Ok(chunk.text)
+ })
+ .collect()
+ }
+
+ pub(crate) fn synth_chunk(
+ &mut self,
+ prepared: &AprilPreparedPrompt,
+ style: &VoiceStyle,
+ ) -> Result, String> {
+ let voice_embeddings = self.voice_embeddings(style)?;
+ let mut flow_state = self.condition_voice(&voice_embeddings)?;
+ let token_ids = self
+ .tokenizer
+ .encode(prepared.text.as_str(), false)
+ .map_err(|err| format!("tokenize Pocket TTS prompt: {err}"))?
+ .get_ids()
+ .iter()
+ .copied()
+ .map(i64::from)
+ .collect::>();
+ if token_ids.is_empty() {
+ return Ok(Vec::new());
+ }
+ if token_ids.len() > self.bundle.max_token_per_chunk {
+ return Err(format!(
+ "Pocket TTS prompt has {} tokens; split_text_into_chunks maximum is {}",
+ token_ids.len(),
+ self.bundle.max_token_per_chunk
+ ));
+ }
+
+ let token_count = token_ids.len();
+ let text_embeddings = self.text_embeddings(token_ids)?;
+ self.run_flow_main_prefix(&text_embeddings, &mut flow_state)?;
+ let max_frames = estimate_max_frames(token_count, self.bundle.frame_rate);
+ let latents =
+ self.generate_latents(max_frames, prepared.frames_after_eos, &mut flow_state)?;
+ self.decode_latents(&latents)
+ }
+
+ fn prepared_token_count(&self, text: &str) -> Result {
+ let prepared = prepare_april_prompt(text)
+ .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?;
+ self.token_count(&prepared.text)
+ }
+
+ fn token_count(&self, text: &str) -> Result {
+ Ok(self
+ .tokenizer
+ .encode(text, false)
+ .map_err(|err| format!("tokenize Pocket TTS prompt: {err}"))?
+ .get_ids()
+ .len())
+ }
+
+ fn voice_embeddings(&mut self, style: &VoiceStyle) -> Result, String> {
+ let key = (
+ style.samples.as_ptr() as usize,
+ style.samples.len(),
+ style.sample_rate,
+ );
+ if let Some(cached) = &self.cached_voice {
+ if (cached.samples_ptr, cached.samples_len, cached.sample_rate) == key {
+ return Ok(cached.embeddings.clone());
+ }
+ }
+
+ let samples = if style.sample_rate == self.bundle.sample_rate as i32 {
+ style.samples.clone()
+ } else {
+ LinearResampler::create(style.sample_rate, self.bundle.sample_rate as i32)
+ .ok_or_else(|| {
+ format!(
+ "create Pocket TTS resampler {}Hz -> {}Hz",
+ style.sample_rate, self.bundle.sample_rate
+ )
+ })?
+ .resample(&style.samples, true)
+ };
+ let audio = Tensor::from_array((
+ vec![1_i64, 1, samples.len() as i64],
+ samples.into_boxed_slice(),
+ ))
+ .map_err(ort_error("create voice audio tensor"))?;
+ let outputs = self
+ .mimi_encoder
+ .run(ort::inputs!["audio" => audio])
+ .map_err(ort_error("run Mimi encoder"))?;
+ let (_, encoded) = outputs[0]
+ .try_extract_tensor::()
+ .map_err(ort_error("extract Mimi encoder output"))?;
+ if !encoded.len().is_multiple_of(self.bundle.conditioning_dim) {
+ return Err(format!(
+ "Mimi encoder returned {} values, not divisible by {}",
+ encoded.len(),
+ self.bundle.conditioning_dim
+ ));
+ }
+ let mut embeddings =
+ Vec::with_capacity(self.bos_embedding.len().saturating_add(encoded.len()));
+ embeddings.extend_from_slice(&self.bos_embedding);
+ embeddings.extend_from_slice(encoded);
+ self.cached_voice = Some(CachedVoice {
+ samples_ptr: key.0,
+ samples_len: key.1,
+ sample_rate: key.2,
+ embeddings: embeddings.clone(),
+ });
+ Ok(embeddings)
+ }
+
+ fn condition_voice(&mut self, embeddings: &[f32]) -> Result, String> {
+ let frames = embeddings.len() / self.bundle.conditioning_dim;
+ let sequence = Tensor::::new(
+ &ort::memory::Allocator::default(),
+ [1_i64, 0, self.bundle.latent_dim as i64],
+ )
+ .map_err(ort_error("create empty voice sequence"))?;
+ let text_embeddings = Tensor::from_array((
+ vec![1_i64, frames as i64, self.bundle.conditioning_dim as i64],
+ embeddings.to_vec().into_boxed_slice(),
+ ))
+ .map_err(ort_error("create voice embedding tensor"))?;
+ let mut state = initialize_state(&self.bundle.flow_lm_state_manifest)?;
+ let mut inputs = vec![
+ (Cow::Borrowed("sequence"), SessionInputValue::from(sequence)),
+ (
+ Cow::Borrowed("text_embeddings"),
+ SessionInputValue::from(text_embeddings),
+ ),
+ ];
+ append_state_inputs(&mut inputs, &state);
+ let mut outputs = self
+ .flow_main
+ .run(inputs)
+ .map_err(ort_error("condition Pocket TTS voice"))?;
+ replace_state_from_outputs(&mut state, &mut outputs)?;
+ Ok(state)
+ }
+
+ fn text_embeddings(&mut self, token_ids: Vec) -> Result, String> {
+ let tokens = Tensor::from_array((
+ vec![1_i64, token_ids.len() as i64],
+ token_ids.into_boxed_slice(),
+ ))
+ .map_err(ort_error("create token tensor"))?;
+ let outputs = self
+ .text_conditioner
+ .run(ort::inputs!["token_ids" => tokens])
+ .map_err(ort_error("run text conditioner"))?;
+ let (_, embeddings) = outputs[0]
+ .try_extract_tensor::()
+ .map_err(ort_error("extract text embeddings"))?;
+ Ok(embeddings.to_vec())
+ }
+
+ fn run_flow_main_prefix(
+ &mut self,
+ text_embeddings: &[f32],
+ state: &mut [StateValue],
+ ) -> Result<(), String> {
+ if !text_embeddings
+ .len()
+ .is_multiple_of(self.bundle.conditioning_dim)
+ {
+ return Err(format!(
+ "text conditioner returned {} values, not divisible by {}",
+ text_embeddings.len(),
+ self.bundle.conditioning_dim
+ ));
+ }
+ let frames = text_embeddings.len() / self.bundle.conditioning_dim;
+ let sequence = Tensor::::new(
+ &ort::memory::Allocator::default(),
+ [1_i64, 0, self.bundle.latent_dim as i64],
+ )
+ .map_err(ort_error("create empty text sequence"))?;
+ let text_embeddings = Tensor::from_array((
+ vec![1_i64, frames as i64, self.bundle.conditioning_dim as i64],
+ text_embeddings.to_vec().into_boxed_slice(),
+ ))
+ .map_err(ort_error("create text embedding tensor"))?;
+ let mut inputs = vec![
+ (Cow::Borrowed("sequence"), SessionInputValue::from(sequence)),
+ (
+ Cow::Borrowed("text_embeddings"),
+ SessionInputValue::from(text_embeddings),
+ ),
+ ];
+ append_state_inputs(&mut inputs, state);
+ let mut outputs = self
+ .flow_main
+ .run(inputs)
+ .map_err(ort_error("prime Pocket TTS text state"))?;
+ replace_state_from_outputs(state, &mut outputs)
+ }
+
+ fn generate_latents(
+ &mut self,
+ max_frames: usize,
+ frames_after_eos: usize,
+ state: &mut [StateValue],
+ ) -> Result, String> {
+ let mut current = vec![f32::NAN; self.bundle.latent_dim];
+ let mut latents = Vec::with_capacity(max_frames * self.bundle.latent_dim);
+ let mut eos_step = None;
+ let mut rng = rand::rng();
+
+ for step in 0..max_frames {
+ let sequence = Tensor::from_array((
+ vec![1_i64, 1, self.bundle.latent_dim as i64],
+ current.clone().into_boxed_slice(),
+ ))
+ .map_err(ort_error("create latent input"))?;
+ let text_embeddings = Tensor::::new(
+ &ort::memory::Allocator::default(),
+ [1_i64, 0, self.bundle.conditioning_dim as i64],
+ )
+ .map_err(ort_error("create empty text input"))?;
+ let mut inputs = vec![
+ (Cow::Borrowed("sequence"), SessionInputValue::from(sequence)),
+ (
+ Cow::Borrowed("text_embeddings"),
+ SessionInputValue::from(text_embeddings),
+ ),
+ ];
+ append_state_inputs(&mut inputs, state);
+ let mut outputs = self
+ .flow_main
+ .run(inputs)
+ .map_err(ort_error("run Pocket TTS Flow LM"))?;
+ let conditioning = outputs[0]
+ .try_extract_tensor::()
+ .map_err(ort_error("extract Flow LM conditioning"))?
+ .1
+ .to_vec();
+ let eos_logit = outputs[1]
+ .try_extract_tensor::()
+ .map_err(ort_error("extract Flow LM EOS logit"))?
+ .1
+ .first()
+ .copied()
+ .ok_or_else(|| "Flow LM returned empty EOS logit".to_string())?;
+ replace_state_from_outputs(state, &mut outputs)?;
+
+ if eos_logit > EOS_LOGIT_THRESHOLD && eos_step.is_none() {
+ eos_step = Some(step);
+ }
+ if eos_step.is_some_and(|eos| step >= eos + frames_after_eos) {
+ break;
+ }
+
+ let mut noise =
+ normal_noise(&mut rng, self.bundle.latent_dim, DEFAULT_TEMPERATURE.sqrt());
+ let conditioning = Tensor::from_array((
+ vec![1_i64, self.bundle.conditioning_dim as i64],
+ conditioning.into_boxed_slice(),
+ ))
+ .map_err(ort_error("create flow conditioning"))?;
+ let s = Tensor::from_array((vec![1_i64, 1], vec![0.0_f32].into_boxed_slice()))
+ .map_err(ort_error("create flow start tensor"))?;
+ let t = Tensor::from_array((vec![1_i64, 1], vec![1.0_f32].into_boxed_slice()))
+ .map_err(ort_error("create flow end tensor"))?;
+ let x = Tensor::from_array((
+ vec![1_i64, self.bundle.latent_dim as i64],
+ noise.clone().into_boxed_slice(),
+ ))
+ .map_err(ort_error("create flow noise tensor"))?;
+ let outputs = self
+ .flow
+ .run(ort::inputs![
+ "c" => conditioning,
+ "s" => s,
+ "t" => t,
+ "x" => x,
+ ])
+ .map_err(ort_error("run Pocket TTS flow"))?;
+ let flow = outputs[0]
+ .try_extract_tensor::()
+ .map_err(ort_error("extract Pocket TTS flow"))?
+ .1;
+ if flow.len() != noise.len() {
+ return Err(format!(
+ "flow returned {} values; expected {}",
+ flow.len(),
+ noise.len()
+ ));
+ }
+ for (sample, delta) in noise.iter_mut().zip(flow) {
+ *sample += *delta;
+ }
+ current.clone_from(&noise);
+ latents.extend_from_slice(&noise);
+ }
+ Ok(latents)
+ }
+
+ fn decode_latents(&mut self, latents: &[f32]) -> Result, String> {
+ if latents.is_empty() {
+ return Ok(Vec::new());
+ }
+ if !latents.len().is_multiple_of(self.bundle.latent_dim) {
+ return Err(format!(
+ "latent buffer has {} values, not divisible by {}",
+ latents.len(),
+ self.bundle.latent_dim
+ ));
+ }
+ let frame_count = latents.len() / self.bundle.latent_dim;
+ let mut state = initialize_state(&self.bundle.mimi_state_manifest)?;
+ let mut audio = Vec::new();
+
+ for start in (0..frame_count).step_by(DECODER_CHUNK_FRAMES) {
+ let end = (start + DECODER_CHUNK_FRAMES).min(frame_count);
+ let values =
+ latents[start * self.bundle.latent_dim..end * self.bundle.latent_dim].to_vec();
+ let latent = Tensor::from_array((
+ vec![1_i64, (end - start) as i64, self.bundle.latent_dim as i64],
+ values.into_boxed_slice(),
+ ))
+ .map_err(ort_error("create Mimi latent tensor"))?;
+ let mut inputs = vec![(Cow::Borrowed("latent"), SessionInputValue::from(latent))];
+ append_state_inputs(&mut inputs, &state);
+ let mut outputs = self
+ .mimi_decoder
+ .run(inputs)
+ .map_err(ort_error("run Mimi decoder"))?;
+ let samples = outputs[0]
+ .try_extract_tensor::()
+ .map_err(ort_error("extract Mimi audio"))?
+ .1;
+ audio.extend_from_slice(samples);
+ replace_state_from_outputs(&mut state, &mut outputs)?;
+ }
+ Ok(audio)
+ }
+}
+
+fn load_session(path: PathBuf, num_threads: usize) -> Result {
+ if !path.is_file() {
+ return Err(format!("missing Pocket TTS file: {}", path.display()));
+ }
+ Session::builder()
+ .map_err(ort_error("create ONNX session builder"))?
+ .with_intra_threads(num_threads)
+ .map_err(|err| format!("configure ONNX intra-op threads: {err}"))?
+ .with_inter_threads(1)
+ .map_err(|err| format!("configure ONNX inter-op threads: {err}"))?
+ .commit_from_file(&path)
+ .map_err(|err| format!("load {}: {err}", path.display()))
+}
+
+fn load_tokenizer(path: &Path) -> Result {
+ let sentencepiece = SentencePieceModel::from_file(path)
+ .map_err(|err| format!("load {}: {err}", path.display()))?;
+ let trainer = sentencepiece
+ .trainer()
+ .ok_or_else(|| format!("{} has no SentencePiece trainer metadata", path.display()))?;
+ let normalizer = sentencepiece.normalizer().ok_or_else(|| {
+ format!(
+ "{} has no SentencePiece normalizer metadata",
+ path.display()
+ )
+ })?;
+ if normalizer.name() != "identity" {
+ return Err(format!(
+ "{} uses unsupported SentencePiece normalizer {:?}",
+ path.display(),
+ normalizer.name()
+ ));
+ }
+
+ let vocab = sentencepiece
+ .pieces()
+ .iter()
+ .map(|piece| (piece.piece().to_owned(), f64::from(piece.score())))
+ .collect();
+ let mut tokenizer = Tokenizer::new(
+ Unigram::from(
+ vocab,
+ Some(trainer.unk_id() as usize),
+ trainer.byte_fallback(),
+ )
+ .map_err(|err| format!("construct tokenizer from {}: {err}", path.display()))?,
+ );
+ // SentencePiece's identity normalizer still escapes spaces as U+2581 and
+ // prepends one marker to the input before unigram segmentation.
+ tokenizer.with_pre_tokenizer(Some(Metaspace::new('▁', PrependScheme::Always, false)));
+ Ok(tokenizer)
+}
+
+fn initialize_state(specs: &[StateSpec]) -> Result, String> {
+ specs
+ .iter()
+ .cloned()
+ .map(|spec| {
+ let len = shape_len(&spec.shape)?;
+ let value = match spec.dtype {
+ StateDtype::Float32 => {
+ let fill = match spec.fill {
+ StateFill::Nan => f32::NAN,
+ StateFill::Empty | StateFill::Zeros => 0.0,
+ StateFill::Ones => 1.0,
+ };
+ if len == 0 {
+ Tensor::::new(&ort::memory::Allocator::default(), spec.shape.clone())
+ .map_err(ort_error("create empty float state tensor"))?
+ .into_dyn()
+ } else {
+ Tensor::from_array((spec.shape.clone(), vec![fill; len].into_boxed_slice()))
+ .map_err(ort_error("create float state tensor"))?
+ .into_dyn()
+ }
+ }
+ StateDtype::Int64 => {
+ let fill = i64::from(matches!(spec.fill, StateFill::Ones));
+ if len == 0 {
+ Tensor::::new(&ort::memory::Allocator::default(), spec.shape.clone())
+ .map_err(ort_error("create empty integer state tensor"))?
+ .into_dyn()
+ } else {
+ Tensor::from_array((spec.shape.clone(), vec![fill; len].into_boxed_slice()))
+ .map_err(ort_error("create integer state tensor"))?
+ .into_dyn()
+ }
+ }
+ StateDtype::Bool => {
+ let fill = matches!(spec.fill, StateFill::Ones);
+ if len == 0 {
+ Tensor::::new(&ort::memory::Allocator::default(), spec.shape.clone())
+ .map_err(ort_error("create empty bool state tensor"))?
+ .into_dyn()
+ } else {
+ Tensor::from_array((spec.shape.clone(), vec![fill; len].into_boxed_slice()))
+ .map_err(ort_error("create bool state tensor"))?
+ .into_dyn()
+ }
+ }
+ };
+ Ok(StateValue { spec, value })
+ })
+ .collect()
+}
+
+fn append_state_inputs<'a>(
+ inputs: &mut Vec<(Cow<'a, str>, SessionInputValue<'a>)>,
+ state: &'a [StateValue],
+) {
+ for value in state {
+ inputs.push((
+ Cow::Borrowed(value.spec.input_name.as_str()),
+ SessionInputValue::from(&value.value),
+ ));
+ }
+}
+
+fn replace_state_from_outputs(
+ state: &mut [StateValue],
+ outputs: &mut ort::session::SessionOutputs<'_>,
+) -> Result<(), String> {
+ for value in state {
+ value.value = outputs
+ .remove(&value.spec.output_name)
+ .ok_or_else(|| format!("missing state output {}", value.spec.output_name))?;
+ }
+ Ok(())
+}
+
+fn shape_len(shape: &[i64]) -> Result {
+ shape.iter().try_fold(1_usize, |len, &dim| {
+ let dim = usize::try_from(dim).map_err(|_| format!("negative state dimension {dim}"))?;
+ len.checked_mul(dim)
+ .ok_or_else(|| format!("state shape overflows usize: {shape:?}"))
+ })
+}
+
+fn estimate_max_frames(token_count: usize, frame_rate: f32) -> usize {
+ ((token_count as f32 / TOKENS_PER_SECOND_ESTIMATE + GENERATION_SECONDS_PADDING) * frame_rate)
+ .ceil() as usize
+}
+
+fn normal_noise(rng: &mut impl Rng, len: usize, std_dev: f32) -> Vec {
+ let mut out = Vec::with_capacity(len);
+ while out.len() < len {
+ let u1 = rng.random::().max(f32::MIN_POSITIVE);
+ let u2 = rng.random::();
+ let radius = (-2.0_f32 * u1.ln()).sqrt() * std_dev;
+ out.push(radius * (TAU * u2).cos());
+ if out.len() < len {
+ out.push(radius * (TAU * u2).sin());
+ }
+ }
+ out
+}
+
+fn read_npy_f32(path: &Path) -> Result, String> {
+ let bytes = fs::read(path).map_err(|err| format!("read {}: {err}", path.display()))?;
+ if bytes.len() < 10 || &bytes[..6] != b"\x93NUMPY" {
+ return Err(format!("{} is not a NumPy array", path.display()));
+ }
+ let major = bytes[6];
+ let header_len_bytes = match major {
+ 1 => 2,
+ 2 | 3 => 4,
+ _ => {
+ return Err(format!(
+ "unsupported NumPy version {major} in {}",
+ path.display()
+ ))
+ }
+ };
+ let header_start = 8 + header_len_bytes;
+ if bytes.len() < header_start {
+ return Err(format!("truncated NumPy header in {}", path.display()));
+ }
+ let header_len = if header_len_bytes == 2 {
+ u16::from_le_bytes([bytes[8], bytes[9]]) as usize
+ } else {
+ u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize
+ };
+ let data_start = header_start
+ .checked_add(header_len)
+ .ok_or_else(|| format!("NumPy header overflow in {}", path.display()))?;
+ if data_start > bytes.len() {
+ return Err(format!("truncated NumPy data in {}", path.display()));
+ }
+ let header = std::str::from_utf8(&bytes[header_start..data_start])
+ .map_err(|err| format!("invalid NumPy header in {}: {err}", path.display()))?;
+ if !(header.contains("'descr': ' impl FnOnce(ort::Error) -> String {
+ move |err| format!("{context}: {err}")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn shape_len_supports_empty_state_dimensions() {
+ assert_eq!(shape_len(&[1, 128, 0]).expect("shape"), 0);
+ assert_eq!(shape_len(&[2, 1, 8, 1000, 64]).expect("shape"), 1_024_000);
+ }
+
+ #[test]
+ fn normal_noise_has_requested_length() {
+ let mut rng = rand::rng();
+ assert_eq!(normal_noise(&mut rng, 1, 1.0).len(), 1);
+ assert_eq!(normal_noise(&mut rng, 32, 1.0).len(), 32);
+ }
+
+ #[test]
+ fn generation_frame_estimate_scales_with_token_count() {
+ assert_eq!(estimate_max_frames(3, 12.5), 38);
+ assert_eq!(estimate_max_frames(300, 12.5), 1_275);
+ }
+
+ #[test]
+ #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"]
+ fn tokenizer_matches_sentencepiece_reference_including_unknown_words() {
+ let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR")
+ .expect("set BUZZ_POCKET_TEST_MODEL_DIR to the verified April bundle");
+ let tokenizer =
+ load_tokenizer(&Path::new(&dir).join("tokenizer.model")).expect("load April tokenizer");
+ let cases: &[(&str, &[u32])] = &[
+ ("Yep.", &[2462, 263]),
+ ("Hello there.", &[2994, 310, 263]),
+ (
+ "quizzaciously xyzzy.",
+ &[
+ 260, 1157, 1818, 362, 1814, 323, 260, 568, 327, 1818, 327, 263,
+ ],
+ ),
+ ("I'm listening.", &[268, 264, 283, 260, 604, 273, 263]),
+ ];
+ for (text, expected) in cases {
+ let encoding = tokenizer.encode(*text, false).expect("tokenize");
+ assert_eq!(encoding.get_ids(), *expected, "{text}");
+ }
+ }
+
+ #[test]
+ #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"]
+ fn loader_splits_oversized_prompts_at_bundle_token_limit() {
+ let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR")
+ .expect("set BUZZ_POCKET_TEST_MODEL_DIR to the verified April bundle");
+ let engine = AprilPocketTts::load(Path::new(&dir), 1).expect("load April bundle");
+ let text = "This deliberately long sentence repeats ordinary English words so the exact SentencePiece token limit is exercised without relying on punctuation, and it keeps adding more material until the prompt must be divided into multiple independently safe generation chunks before the recurrent state cache can be exhausted.";
+ let prepared = prepare_april_prompt(text).expect("prepare prompt");
+ let chunks = engine.split_prompt(&prepared).expect("split prompt");
+
+ assert!(chunks.len() > 1);
+ assert!(chunks.iter().all(|chunk| {
+ engine.token_count(chunk).expect("tokenize chunk") <= engine.bundle.max_token_per_chunk
+ }));
+ }
+
+ #[test]
+ #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"]
+ fn gary_provost_long_sentence_respects_bundle_token_limit() {
+ let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR")
+ .expect("set BUZZ_POCKET_TEST_MODEL_DIR to the verified April bundle");
+ let engine = AprilPocketTts::load(Path::new(&dir), 1).expect("load April bundle");
+ let text = "And sometimes, when I am certain the reader is rested, I will engage him with a sentence of considerable length, a sentence that burns with energy and builds with all the impetus of a crescendo, the roll of the drums, the crash of the cymbals–sounds that say listen to this, it is important.";
+ let prepared = prepare_april_prompt(text).expect("prepare prompt");
+ let chunks = engine.split_prompt(&prepared).expect("split long sentence");
+ let token_counts: Vec<_> = chunks
+ .iter()
+ .map(|chunk| engine.token_count(chunk).expect("count tokens"))
+ .collect();
+
+ assert_eq!(
+ chunks,
+ [
+ "And sometimes, when I am certain the reader is rested, I will engage him with a sentence of considerable length, a sentence that burns with energy and builds with all the.",
+ "Impetus of a crescendo, the roll of the drums, the crash of the cymbals–sounds that say listen to this, it is important.",
+ ]
+ );
+ assert_eq!(token_counts, [48, 44]);
+ }
+}
diff --git a/crates/buzz-voice/src/pocket_models.rs b/crates/buzz-voice/src/pocket_models.rs
new file mode 100644
index 0000000000..ba3f92849c
--- /dev/null
+++ b/crates/buzz-voice/src/pocket_models.rs
@@ -0,0 +1,137 @@
+//! Immutable capabilities for Buzz Desktop's April Pocket TTS bundle.
+
+/// Pinned upstream export repository.
+pub const APRIL_MODEL_ID: &str = "KevinAHM/pocket-tts-onnx";
+
+/// Pinned revision containing the `english_2026-04` bundle.
+pub const APRIL_MODEL_REVISION: &str = "58a6d00cf13d239b6748cb0769f35c580a8f606c";
+
+/// Language bundle selected from the pinned export.
+pub const APRIL_BUNDLE_ID: &str = "english_2026-04";
+
+/// Maximum input size declared by the April bundle.
+pub const APRIL_MAX_TOKEN_PER_CHUNK: usize = 50;
+
+/// One immutable artifact required by the April INT8 runtime.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PocketModelArtifact {
+ pub filename: &'static str,
+ pub sha256: &'static str,
+ pub size_bytes: u64,
+ pub quantized: bool,
+}
+
+/// Capabilities of Buzz Desktop's sole Pocket model.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PocketModelInfo {
+ /// Language bundle selected from the pinned export.
+ pub bundle_id: &'static str,
+ /// Upstream model repository.
+ pub source_model_id: &'static str,
+ /// Pinned upstream model revision.
+ pub revision: &'static str,
+ /// PCM output sample rate.
+ pub sample_rate: u32,
+ /// Maximum input size declared by the bundle.
+ pub max_token_per_chunk: usize,
+ /// Immutable files required by the runtime.
+ pub artifacts: &'static [PocketModelArtifact],
+ /// Components quantized in the selected bundle.
+ pub quantized_components: &'static [&'static str],
+}
+
+const INT8_ARTIFACTS: [PocketModelArtifact; 8] = [
+ PocketModelArtifact {
+ filename: "bundle.json",
+ sha256: "bab643150f437f37df080a710520ff39ed9ebd9a339f8ebdc739f7eddfc28b3f",
+ size_bytes: 24_381,
+ quantized: false,
+ },
+ PocketModelArtifact {
+ filename: "bos_before_voice.npy",
+ sha256: "f46edf4f7007b7ba4ea58831f49d003e59e167b4641c44bb3addfe9231a780b1",
+ size_bytes: 4_224,
+ quantized: false,
+ },
+ PocketModelArtifact {
+ filename: "tokenizer.model",
+ sha256: "d461765ae179566678c93091c5fa6f2984c31bbe990bf1aa62d92c64d91bc3f6",
+ size_bytes: 59_339,
+ quantized: false,
+ },
+ PocketModelArtifact {
+ filename: "flow_lm_main_int8.onnx",
+ sha256: "f9bd8106b79a0192c1c43399ab938fb24900a95c1c599870d75a884e99000116",
+ size_bytes: 76_341_079,
+ quantized: true,
+ },
+ PocketModelArtifact {
+ filename: "flow_lm_flow_int8.onnx",
+ sha256: "3dd781ee5abee9e195320bf0106bebd6372a852b3b36352524ee78b40554635d",
+ size_bytes: 9_962_530,
+ quantized: true,
+ },
+ PocketModelArtifact {
+ filename: "mimi_decoder_int8.onnx",
+ sha256: "3630450a3297a101792a6ac66619ebc70ab916b265e6220c2afaef8b1673f925",
+ size_bytes: 22_684_077,
+ quantized: true,
+ },
+ PocketModelArtifact {
+ filename: "mimi_encoder.onnx",
+ sha256: "853e2ca623b8782d94c3745ec6133bfdff7ce33d9b11128bd29ea03f28d76e3d",
+ size_bytes: 39_768_446,
+ quantized: false,
+ },
+ PocketModelArtifact {
+ filename: "text_conditioner.onnx",
+ sha256: "4ecee995fb69f85c7a7493d11f7b5ee15d9950facc7ab3f5c9c49ef1e03847bb",
+ size_bytes: 16_388_344,
+ quantized: false,
+ },
+];
+
+const INT8_COMPONENTS: [&str; 3] = ["flow_lm_main", "flow_lm_flow", "mimi_decoder"];
+
+/// Return immutable metadata for Buzz Desktop's April INT8 model.
+pub const fn april_model_info() -> PocketModelInfo {
+ PocketModelInfo {
+ bundle_id: APRIL_BUNDLE_ID,
+ source_model_id: APRIL_MODEL_ID,
+ revision: APRIL_MODEL_REVISION,
+ sample_rate: 24_000,
+ max_token_per_chunk: APRIL_MAX_TOKEN_PER_CHUNK,
+ artifacts: &INT8_ARTIFACTS,
+ quantized_components: &INT8_COMPONENTS,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn metadata_matches_pinned_int8_layout() {
+ let info = april_model_info();
+ assert_eq!(info.artifacts.len(), 8);
+ assert_eq!(
+ info.quantized_components,
+ ["flow_lm_main", "flow_lm_flow", "mimi_decoder"]
+ );
+ assert_eq!(
+ info.artifacts
+ .iter()
+ .map(|artifact| artifact.size_bytes)
+ .sum::(),
+ 165_232_420
+ );
+ assert!(info
+ .artifacts
+ .iter()
+ .any(|artifact| { artifact.filename == "mimi_encoder.onnx" && !artifact.quantized }));
+ assert!(!info
+ .artifacts
+ .iter()
+ .any(|artifact| artifact.filename == "mimi_encoder_int8.onnx"));
+ }
+}
diff --git a/crates/buzz-voice/tests/pocket_import_audio.rs b/crates/buzz-voice/tests/pocket_import_audio.rs
new file mode 100644
index 0000000000..8578c368d4
--- /dev/null
+++ b/crates/buzz-voice/tests/pocket_import_audio.rs
@@ -0,0 +1,133 @@
+use std::{
+ fs,
+ path::{Path, PathBuf},
+};
+
+use buzz_voice::{
+ imported::{write_pcm16_wav, PcmStats, PocketVoiceLibrary},
+ pocket::{load_text_to_speech, load_voice_style, DEFAULT_VOICE, SAMPLE_RATE, VOICE_FILE_EXT},
+};
+
+const PREVIEW_TEXT: &str = "This is an objective Pocket voice preview.";
+
+fn required_path(name: &str) -> PathBuf {
+ std::env::var_os(name)
+ .map(PathBuf::from)
+ .unwrap_or_else(|| panic!("{name} must point to the required local test path"))
+}
+
+fn checked_in_voice() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../../desktop/src-tauri/resources/pocket-voices/eve.wav")
+}
+
+fn evidence_dir() -> PathBuf {
+ std::env::var_os("BUZZ_VOICE_EVIDENCE_DIR")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| {
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/buzz-voice-evidence")
+ })
+}
+
+fn synthesize(model_dir: &Path, voice_path: &Path, text: &str) -> (Vec, PcmStats) {
+ let engine = load_text_to_speech(
+ model_dir
+ .to_str()
+ .expect("Pocket model path must be valid UTF-8"),
+ )
+ .expect("load Pocket model");
+ let style = load_voice_style(voice_path).expect("load selected voice");
+ let samples = engine
+ .synth_chunk(text, "en", &style, 1)
+ .expect("synthesize preview");
+ let stats = PcmStats::analyze(&samples, SAMPLE_RATE);
+ assert!(
+ stats.is_non_silent(),
+ "generated PCM must be non-silent: {stats:?}"
+ );
+ assert!(
+ stats.duration_seconds > 0.2,
+ "generated PCM is unexpectedly short: {stats:?}"
+ );
+ (samples, stats)
+}
+
+#[test]
+#[ignore = "requires BUZZ_POCKET_MODEL_DIR and runs the installed Pocket ONNX model"]
+fn objective_import_synthesis_delete_and_mary_fallback() {
+ let model_dir = required_path("BUZZ_POCKET_MODEL_DIR");
+ let temp = tempfile::tempdir().expect("temporary voice workspace");
+ let source = temp.path().join("Imported Eve.wav");
+ fs::copy(checked_in_voice(), &source).expect("copy checked-in voice fixture");
+
+ let library_root = temp.path().join("library");
+ let library = PocketVoiceLibrary::new(&library_root);
+ let imported = library.import_path(&source).expect("import valid WAV");
+ assert_eq!(
+ library.find(&imported.key).expect("read selection"),
+ Some(imported.clone())
+ );
+
+ drop(library);
+ let relaunched = PocketVoiceLibrary::new(&library_root);
+ let selected = relaunched
+ .find(&imported.key)
+ .expect("reload persisted selection")
+ .expect("selected imported voice survived relaunch");
+ let imported_path = relaunched
+ .resolve_file(&selected)
+ .expect("resolve persisted imported voice");
+ let (imported_pcm, imported_stats) = synthesize(&model_dir, &imported_path, PREVIEW_TEXT);
+
+ let evidence = evidence_dir();
+ fs::create_dir_all(&evidence).expect("create evidence directory");
+ let imported_wav = evidence.join("imported-preview.wav");
+ write_pcm16_wav(&imported_wav, &imported_pcm, SAMPLE_RATE)
+ .expect("write imported preview evidence");
+
+ relaunched
+ .delete(&imported.key)
+ .expect("delete imported voice");
+ assert_eq!(
+ relaunched.find(&imported.key).expect("reload after delete"),
+ None
+ );
+
+ let mary_path = model_dir.join(format!("{DEFAULT_VOICE}.{VOICE_FILE_EXT}"));
+ assert_eq!(
+ mary_path.file_name().and_then(|name| name.to_str()),
+ Some("reference_sample.wav"),
+ "fallback must remain the deterministic Mary reference"
+ );
+ let (mary_pcm, mary_stats) = synthesize(&model_dir, &mary_path, PREVIEW_TEXT);
+ let mary_wav = evidence.join("mary-fallback-preview.wav");
+ write_pcm16_wav(&mary_wav, &mary_pcm, SAMPLE_RATE)
+ .expect("write Mary fallback preview evidence");
+
+ println!(
+ "{}",
+ serde_json::json!({
+ "importedKey": imported.key,
+ "persistence": "reloaded",
+ "afterDelete": "pocket:mary",
+ "importedPreview": {
+ "path": imported_wav,
+ "samples": imported_stats.sample_count,
+ "sampleRate": imported_stats.sample_rate,
+ "durationSeconds": imported_stats.duration_seconds,
+ "peak": imported_stats.peak,
+ "rms": imported_stats.rms,
+ "nonSilentSamples": imported_stats.non_silent_samples,
+ },
+ "maryFallbackPreview": {
+ "path": mary_wav,
+ "samples": mary_stats.sample_count,
+ "sampleRate": mary_stats.sample_rate,
+ "durationSeconds": mary_stats.duration_seconds,
+ "peak": mary_stats.peak,
+ "rms": mary_stats.rms,
+ "nonSilentSamples": mary_stats.non_silent_samples,
+ }
+ })
+ );
+}
diff --git a/desktop/package.json b/desktop/package.json
index 2226a0cb12..e05ab1a264 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -31,6 +31,8 @@
"@emoji-mart/react": "^1.1.1",
"@fontsource-variable/inter": "^5.2.8",
"@mediapipe/tasks-vision": "^0.10.35",
+ "@modelcontextprotocol/ext-apps": "1.7.5",
+ "@modelcontextprotocol/sdk": "1.29.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index b86406d9b0..6f4e555f69 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -44,11 +44,13 @@ export default defineConfig({
"**/active-turn-resilience.spec.ts",
"**/profile-active-turn.spec.ts",
"**/config-bridge-screenshots.spec.ts",
+ "**/mcp-app-channel-screenshots.spec.ts",
"**/observer-feed-screenshots.spec.ts",
"**/core-memory-screenshots.spec.ts",
"**/activity-scope-label-screenshots.spec.ts",
"**/welcome-agent-modal-screenshots.spec.ts",
"**/local-archive-screenshots.spec.ts",
+ "**/voice-settings.spec.ts",
"**/agent-readiness-screenshots.spec.ts",
"**/agent-error-state-screenshots.spec.ts",
"**/edit-agent.spec.ts",
@@ -95,6 +97,7 @@ export default defineConfig({
"**/cold-switch-longtask.perf.ts",
"**/timeline-no-shift.spec.ts",
"**/human-edit-agent-content.spec.ts",
+ "**/empty-edit-delete.spec.ts",
"**/reaction-order.spec.ts",
"**/reaction-names.spec.ts",
"**/inbox-reactions.spec.ts",
@@ -130,6 +133,7 @@ export default defineConfig({
"**/harness-management.spec.ts",
"**/harness-catalog-screenshots.spec.ts",
"**/inline-custom-harness.spec.ts",
+ "**/huddle-transcription.spec.ts",
],
use: {
...devices["Desktop Chrome"],
diff --git a/desktop/scripts/check-pubkey-truncation.mjs b/desktop/scripts/check-pubkey-truncation.mjs
index 95e56fb282..d65db13545 100644
--- a/desktop/scripts/check-pubkey-truncation.mjs
+++ b/desktop/scripts/check-pubkey-truncation.mjs
@@ -18,12 +18,10 @@ const rules = [
// Non-display uses: array windows over pubkey lists, color/initials
// derivation where the value is never presented as an identity.
const overrides = new Set([
- // ProfileAvatar fallback label — decorative glyphs inside an avatar disc.
- "src/features/huddle/components/ParticipantList.tsx:92",
// HexAvatar: 6-char badge + hue derivation inside a color-coded disc,
// clearly decorative (paired with a full truncatePubkey aria-label).
- "src/features/huddle/components/ParticipantList.tsx:143",
- "src/features/huddle/components/ParticipantList.tsx:144",
+ "src/features/huddle/components/ParticipantList.tsx:150",
+ "src/features/huddle/components/ParticipantList.tsx:151",
// clientId (not a pubkey) sliced in a debug log next to the real thing.
"src/features/channels/readState/readStateManager.ts:338",
// Array windows (first N pubkeys), not string truncation.
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 325eb9aa67..254b7070ac 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -84,6 +84,7 @@ dependencies = [
"cfg-if 1.0.4",
"getrandom 0.3.4",
"once_cell",
+ "serde",
"version_check",
"zerocopy",
]
@@ -707,6 +708,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
[[package]]
name = "base64"
version = "0.21.7"
@@ -731,6 +738,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
+[[package]]
+name = "beef"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
+
[[package]]
name = "bip39"
version = "2.2.2"
@@ -1036,6 +1049,7 @@ dependencies = [
"buzz-media",
"buzz-persona",
"buzz-sdk",
+ "buzz-voice",
"bytes",
"bzip2 0.6.1",
"chrono",
@@ -1160,6 +1174,24 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "buzz-voice"
+version = "0.1.0"
+dependencies = [
+ "atomic-write-file",
+ "hex",
+ "ort",
+ "ort-sys",
+ "rand 0.10.2",
+ "sentencepiece-model",
+ "serde",
+ "serde_json",
+ "sha2 0.11.0",
+ "sherpa-onnx",
+ "symphonia",
+ "tokenizers",
+]
+
[[package]]
name = "by_address"
version = "1.2.1"
@@ -1562,6 +1594,7 @@ dependencies = [
"itoa",
"rustversion",
"ryu",
+ "serde",
"static_assertions",
]
@@ -1813,6 +1846,16 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
@@ -2137,6 +2180,15 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "dary_heap"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "dasp_sample"
version = "0.11.0"
@@ -2634,6 +2686,12 @@ version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
+[[package]]
+name = "esaxx-rs"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6"
+
[[package]]
name = "euclid"
version = "0.22.14"
@@ -2692,6 +2750,17 @@ dependencies = [
"regex",
]
+[[package]]
+name = "fancy-regex"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
+dependencies = [
+ "bit-set 0.8.0",
+ "regex-automata",
+ "regex-syntax",
+]
+
[[package]]
name = "fast-srgb8"
version = "1.0.0"
@@ -4763,6 +4832,39 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+[[package]]
+name = "logos"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458"
+dependencies = [
+ "logos-derive",
+]
+
+[[package]]
+name = "logos-codegen"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f"
+dependencies = [
+ "beef",
+ "fnv",
+ "lazy_static",
+ "proc-macro2",
+ "quote",
+ "regex-syntax",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "logos-derive"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d"
+dependencies = [
+ "logos-codegen",
+]
+
[[package]]
name = "loom"
version = "0.7.2"
@@ -4872,6 +4974,22 @@ dependencies = [
"libc",
]
+[[package]]
+name = "macro_rules_attribute"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c"
+dependencies = [
+ "macro_rules_attribute-proc_macro",
+ "pastey",
+]
+
+[[package]]
+name = "macro_rules_attribute-proc_macro"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c"
+
[[package]]
name = "markup5ever"
version = "0.38.0"
@@ -4898,6 +5016,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+[[package]]
+name = "matrixmultiply"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
+dependencies = [
+ "autocfg",
+ "rawpointer",
+]
+
[[package]]
name = "maybe-async"
version = "0.2.11"
@@ -4997,7 +5125,7 @@ dependencies = [
"mesh-llm-types",
"model-artifact",
"nostr-sdk",
- "prost",
+ "prost 0.14.4",
"rand 0.10.2",
"rustls",
"serde",
@@ -5135,7 +5263,7 @@ dependencies = [
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
- "prost",
+ "prost 0.14.4",
"rand 0.10.2",
"regex-lite",
"reqwest 0.12.28",
@@ -5224,8 +5352,8 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05
dependencies = [
"anyhow",
"async-trait",
- "prost",
- "prost-build",
+ "prost 0.14.4",
+ "prost-build 0.14.4",
"protoc-bin-vendored",
"rmcp",
"schemars 1.2.1",
@@ -5261,7 +5389,7 @@ dependencies = [
"anyhow",
"hex",
"iroh",
- "prost",
+ "prost 0.14.4",
"serde_json",
"sha2 0.10.9",
]
@@ -5376,6 +5504,28 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "miette"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
+dependencies = [
+ "cfg-if 1.0.4",
+ "miette-derive",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "miette-derive"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
[[package]]
name = "mime"
version = "0.3.17"
@@ -5521,6 +5671,28 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "monostate"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67"
+dependencies = [
+ "monostate-impl",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "monostate-impl"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
[[package]]
name = "more-asserts"
version = "0.3.1"
@@ -5648,6 +5820,21 @@ dependencies = [
"tempfile",
]
+[[package]]
+name = "ndarray"
+version = "0.17.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
+dependencies = [
+ "matrixmultiply",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "portable-atomic",
+ "portable-atomic-util",
+ "rawpointer",
+]
+
[[package]]
name = "ndk"
version = "0.9.0"
@@ -6149,7 +6336,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
- "proc-macro-crate 2.0.2",
+ "proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
@@ -6629,7 +6816,7 @@ dependencies = [
"opentelemetry-http",
"opentelemetry-proto",
"opentelemetry_sdk",
- "prost",
+ "prost 0.14.4",
"reqwest 0.12.28",
"thiserror 2.0.18",
]
@@ -6644,7 +6831,7 @@ dependencies = [
"const-hex",
"opentelemetry",
"opentelemetry_sdk",
- "prost",
+ "prost 0.14.4",
"serde",
"serde_json",
"tonic",
@@ -6710,6 +6897,24 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "ort"
+version = "2.0.0-rc.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
+dependencies = [
+ "ndarray",
+ "ort-sys",
+ "smallvec",
+ "tracing",
+]
+
+[[package]]
+name = "ort-sys"
+version = "2.0.0-rc.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
+
[[package]]
name = "os_pipe"
version = "1.2.3"
@@ -6937,6 +7142,16 @@ dependencies = [
"pest",
]
+[[package]]
+name = "petgraph"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
+dependencies = [
+ "fixedbitset 0.5.7",
+ "indexmap 2.14.0",
+]
+
[[package]]
name = "petgraph"
version = "0.8.3"
@@ -7201,6 +7416,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
[[package]]
name = "portmapper"
version = "0.19.1"
@@ -7411,6 +7635,16 @@ dependencies = [
"unarray",
]
+[[package]]
+name = "prost"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
+dependencies = [
+ "bytes",
+ "prost-derive 0.13.5",
+]
+
[[package]]
name = "prost"
version = "0.14.4"
@@ -7418,7 +7652,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
dependencies = [
"bytes",
- "prost-derive",
+ "prost-derive 0.14.4",
+]
+
+[[package]]
+name = "prost-build"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
+dependencies = [
+ "heck 0.5.0",
+ "itertools",
+ "log",
+ "multimap",
+ "once_cell",
+ "petgraph 0.7.1",
+ "prettyplease",
+ "prost 0.13.5",
+ "prost-types 0.13.5",
+ "regex",
+ "syn 2.0.118",
+ "tempfile",
]
[[package]]
@@ -7431,15 +7685,28 @@ dependencies = [
"itertools",
"log",
"multimap",
- "petgraph",
+ "petgraph 0.8.3",
"prettyplease",
- "prost",
- "prost-types",
+ "prost 0.14.4",
+ "prost-types 0.14.4",
"regex",
"syn 2.0.118",
"tempfile",
]
+[[package]]
+name = "prost-derive"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
+dependencies = [
+ "anyhow",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
[[package]]
name = "prost-derive"
version = "0.14.4"
@@ -7453,13 +7720,35 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "prost-reflect"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2"
+dependencies = [
+ "logos",
+ "miette",
+ "once_cell",
+ "prost 0.13.5",
+ "prost-types 0.13.5",
+]
+
+[[package]]
+name = "prost-types"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
+dependencies = [
+ "prost 0.13.5",
+]
+
[[package]]
name = "prost-types"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
dependencies = [
- "prost",
+ "prost 0.14.4",
]
[[package]]
@@ -7526,6 +7815,33 @@ version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
+[[package]]
+name = "protox"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a"
+dependencies = [
+ "bytes",
+ "miette",
+ "prost 0.13.5",
+ "prost-reflect",
+ "prost-types 0.13.5",
+ "protox-parse",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "protox-parse"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3a462d115462c080ae000c29a47f0b3985737e5d3a995fcdbcaa5c782068dde"
+dependencies = [
+ "logos",
+ "miette",
+ "prost-types 0.13.5",
+ "thiserror 1.0.69",
+]
+
[[package]]
name = "pxfm"
version = "0.1.30"
@@ -7774,7 +8090,7 @@ dependencies = [
"thiserror 2.0.18",
"unicode-segmentation",
"unicode-truncate",
- "unicode-width",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -7837,7 +8153,7 @@ dependencies = [
"strum",
"time",
"unicode-segmentation",
- "unicode-width",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -7846,6 +8162,43 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+[[package]]
+name = "rawpointer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
+
+[[package]]
+name = "rayon"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-cond"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f"
+dependencies = [
+ "either",
+ "itertools",
+ "rayon",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
[[package]]
name = "realfft"
version = "3.5.0"
@@ -8658,6 +9011,18 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
+[[package]]
+name = "sentencepiece-model"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40b87bf750a8322c3236d7aa63c1f4a6862187d00d2d8b038e1dfe263bfe43ec"
+dependencies = [
+ "miette",
+ "prost 0.13.5",
+ "prost-build 0.13.5",
+ "protox",
+]
+
[[package]]
name = "serde"
version = "1.0.228"
@@ -9090,8 +9455,8 @@ name = "skippy-protocol"
version = "0.74.0"
source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
- "prost",
- "prost-build",
+ "prost 0.14.4",
+ "prost-build 0.14.4",
"protoc-bin-vendored",
"serde",
]
@@ -9271,6 +9636,18 @@ dependencies = [
"der",
]
+[[package]]
+name = "spm_precompiled"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326"
+dependencies = [
+ "base64 0.13.1",
+ "nom 7.1.3",
+ "serde",
+ "unicode-segmentation",
+]
+
[[package]]
name = "sse-stream"
version = "0.2.4"
@@ -9405,6 +9782,7 @@ dependencies = [
"symphonia-bundle-flac",
"symphonia-bundle-mp3",
"symphonia-codec-aac",
+ "symphonia-codec-alac",
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
@@ -9449,6 +9827,16 @@ dependencies = [
"symphonia-core",
]
+[[package]]
+name = "symphonia-codec-alac"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
+dependencies = [
+ "log",
+ "symphonia-core",
+]
+
[[package]]
name = "symphonia-codec-pcm"
version = "0.5.5"
@@ -9652,7 +10040,7 @@ version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432"
dependencies = [
- "unicode-width",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -10227,7 +10615,7 @@ dependencies = [
"anyhow",
"base64 0.22.1",
"bitflags 2.13.0",
- "fancy-regex",
+ "fancy-regex 0.11.0",
"filedescriptor",
"finl_unicode",
"fixedbitset 0.4.2",
@@ -10390,6 +10778,39 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+[[package]]
+name = "tokenizers"
+version = "0.22.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223"
+dependencies = [
+ "ahash",
+ "aho-corasick",
+ "compact_str 0.9.1",
+ "dary_heap",
+ "derive_builder",
+ "esaxx-rs",
+ "fancy-regex 0.14.0",
+ "getrandom 0.3.4",
+ "itertools",
+ "log",
+ "macro_rules_attribute",
+ "monostate",
+ "paste",
+ "rand 0.9.4",
+ "rayon",
+ "rayon-cond",
+ "regex",
+ "regex-syntax",
+ "serde",
+ "serde_json",
+ "spm_precompiled",
+ "thiserror 2.0.18",
+ "unicode-normalization-alignments",
+ "unicode-segmentation",
+ "unicode_categories",
+]
+
[[package]]
name = "tokio"
version = "1.52.3"
@@ -10723,7 +11144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
dependencies = [
"bytes",
- "prost",
+ "prost 0.14.4",
"tonic",
]
@@ -10904,7 +11325,7 @@ checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom 8.0.0",
- "petgraph",
+ "petgraph 0.8.3",
]
[[package]]
@@ -11075,6 +11496,15 @@ dependencies = [
"tinyvec",
]
+[[package]]
+name = "unicode-normalization-alignments"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de"
+dependencies = [
+ "smallvec",
+]
+
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
@@ -11089,9 +11519,15 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools",
"unicode-segmentation",
- "unicode-width",
+ "unicode-width 0.2.2",
]
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -11104,6 +11540,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+[[package]]
+name = "unicode_categories"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+
[[package]]
name = "universal-hash"
version = "0.5.1"
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 6f3c03c5a5..39aaf0dead 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -98,6 +98,7 @@ buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" }
buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" }
buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" }
buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" }
+buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" }
iroh = { version = "1.0.2", optional = true }
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true }
mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true }
diff --git a/desktop/src-tauri/examples/pocket_bench.rs b/desktop/src-tauri/examples/pocket_bench.rs
deleted file mode 100644
index b4f5635a95..0000000000
--- a/desktop/src-tauri/examples/pocket_bench.rs
+++ /dev/null
@@ -1,116 +0,0 @@
-//! Cold-vs-warm latency bench for Pocket TTS.
-//!
-//! This duplicates the small config-building snippet from `huddle::pocket` so it
-//! doesn't depend on changing module visibility for a one-off dev tool.
-//! Keep in sync with `huddle::pocket::load_text_to_speech`.
-//!
-//! Run with the model files in a directory (defaults to /tmp/pocket-tts-bench):
-//! cargo run --release --example pocket_bench
-//! cargo run --release --example pocket_bench /path/to/pocket-tts
-
-use std::path::PathBuf;
-use std::time::Instant;
-
-use sherpa_onnx::{
- self, GenerationConfig, OfflineTts, OfflineTtsConfig, OfflineTtsModelConfig,
- OfflineTtsPocketModelConfig, Wave,
-};
-
-const SAMPLE_RATE: u32 = 24_000;
-const TEST_TEXT: &str =
- "Hello, this is a test of the new Pocket TTS engine running on sherpa-onnx.";
-
-fn main() {
- let model_dir = std::env::args()
- .nth(1)
- .unwrap_or_else(|| "/tmp/pocket-tts-bench".to_string());
- println!("Model dir: {model_dir}");
-
- let dir = PathBuf::from(&model_dir);
- let p = |name: &str| dir.join(name).to_string_lossy().into_owned();
-
- let t0 = Instant::now();
- let cfg = OfflineTtsConfig {
- model: OfflineTtsModelConfig {
- pocket: OfflineTtsPocketModelConfig {
- lm_main: Some(p("lm_main.int8.onnx")),
- lm_flow: Some(p("lm_flow.int8.onnx")),
- encoder: Some(p("encoder.onnx")),
- decoder: Some(p("decoder.int8.onnx")),
- text_conditioner: Some(p("text_conditioner.onnx")),
- vocab_json: Some(p("vocab.json")),
- token_scores_json: Some(p("token_scores.json")),
- voice_embedding_cache_capacity: 16,
- },
- num_threads: 1,
- debug: false,
- ..Default::default()
- },
- ..Default::default()
- };
- let engine = OfflineTts::create(&cfg).expect("engine create");
- let load_ms = t0.elapsed().as_secs_f32() * 1000.0;
- println!("Engine load: {load_ms:.1} ms");
-
- let t0 = Instant::now();
- let voice_path = dir.join("reference_sample.wav");
- let wave = Wave::read(voice_path.to_str().unwrap()).expect("voice WAV");
- let samples = wave.samples().to_vec();
- let sr = wave.sample_rate();
- let voice_ms = t0.elapsed().as_secs_f32() * 1000.0;
- println!("Voice load: {voice_ms:.1} ms");
-
- let gen = || GenerationConfig {
- speed: 1.05,
- num_steps: 1,
- silence_scale: 1.0, // production setting (huddle::pocket::SYNTH_SILENCE_SCALE)
- reference_audio: Some(samples.clone()),
- reference_sample_rate: sr,
- ..Default::default()
- };
-
- let t0 = Instant::now();
- let cold = engine
- .generate_with_config(TEST_TEXT, &gen(), None:: bool>)
- .expect("cold synth");
- let cold_ms = t0.elapsed().as_secs_f32() * 1000.0;
- let cold_audio_ms = (cold.samples().len() as f32 / SAMPLE_RATE as f32) * 1000.0;
- let cold_rtf_x = cold_audio_ms / cold_ms;
- println!(
- "Cold synth: {cold_ms:.1} ms → {cold_audio_ms:.1} ms audio → {cold_rtf_x:.2}× realtime"
- );
-
- let t0 = Instant::now();
- let warm = engine
- .generate_with_config(TEST_TEXT, &gen(), None:: bool>)
- .expect("warm synth");
- let warm_ms = t0.elapsed().as_secs_f32() * 1000.0;
- let warm_audio_ms = (warm.samples().len() as f32 / SAMPLE_RATE as f32) * 1000.0;
- let warm_rtf_x = warm_audio_ms / warm_ms;
- println!(
- "Warm synth: {warm_ms:.1} ms → {warm_audio_ms:.1} ms audio → {warm_rtf_x:.2}× realtime"
- );
-
- let out_path = "/tmp/pocket_bench_out.wav";
- let ok = sherpa_onnx::write(out_path, warm.samples(), SAMPLE_RATE as i32);
- println!(
- "Wrote {} ({} samples, ok={ok})",
- out_path,
- warm.samples().len()
- );
-
- let delta_ms = cold_ms - warm_ms;
- let delta_pct = (delta_ms / warm_ms) * 100.0;
- println!();
- println!("Cold/warm delta: {delta_ms:+.1} ms ({delta_pct:+.1}%)");
- println!(
- "Decision: warmup {}.",
- if delta_ms > 200.0 {
- "RECOMMENDED — significant cold-call penalty"
- } else if delta_ms > 50.0 {
- "OPTIONAL — small cold-call penalty"
- } else {
- "UNNECESSARY — cold and warm essentially equal"
- }
- );
-}
diff --git a/desktop/src-tauri/examples/pocket_clip_probe.rs b/desktop/src-tauri/examples/pocket_clip_probe.rs
deleted file mode 100644
index ad8657599f..0000000000
--- a/desktop/src-tauri/examples/pocket_clip_probe.rs
+++ /dev/null
@@ -1,122 +0,0 @@
-//! Clipping probe for any fixed playback gain applied after Pocket TTS synth.
-//!
-//! Synthesises a spread of sentences (short/long, calm/energetic) and reports
-//! the raw peak of each, the post-gain peak, and the fraction of samples that
-//! would hit a ±1.0 clamp — i.e. how much a fixed gain would flat-top the
-//! waveform ("blown out" distortion).
-//!
-//! History: the production pipeline briefly shipped a fixed 9.3× gain
-//! calibrated on a single bench utterance that peaked at 0.076. This probe
-//! showed real output peaks at 0.4–0.97, so that gain clipped 13–34% of all
-//! samples (the 2026-06-12 "blown out" report). Production now applies no
-//! gain — run this probe before reintroducing one.
-//!
-//! Run with model files in ~/.buzz/models/pocket-tts (override with arg 1):
-//! cargo run --release --example pocket_clip_probe
-
-use std::path::PathBuf;
-
-use sherpa_onnx::{
- self, GenerationConfig, OfflineTts, OfflineTtsConfig, OfflineTtsModelConfig,
- OfflineTtsPocketModelConfig, Wave,
-};
-
-/// Candidate gain under test (the regressed production value).
-const GAIN: f32 = 9.3;
-
-const PROMPTS: &[&str] = &[
- "Hello, this is a test of the new Pocket TTS engine running on sherpa-onnx.",
- "Yep, I can hear you.",
- "Absolutely! That sounds fantastic, let's do it right now!",
- "The quick brown fox jumps over the lazy dog near the riverbank.",
- "I found three problems in the code: a race condition, a memory leak, and an off-by-one error in the loop bounds.",
- "No.",
- "Warning! The build failed because seventeen tests crashed unexpectedly!",
- "Sure, I can walk you through the whole pipeline step by step whenever you're ready.",
-];
-
-fn main() {
- let model_dir = std::env::args().nth(1).unwrap_or_else(|| {
- dirs::home_dir()
- .expect("home dir")
- .join(".buzz/models/pocket-tts")
- .to_string_lossy()
- .into_owned()
- });
- eprintln!("Model dir: {model_dir}");
-
- let dir = PathBuf::from(&model_dir);
- let p = |name: &str| dir.join(name).to_string_lossy().into_owned();
-
- let cfg = OfflineTtsConfig {
- model: OfflineTtsModelConfig {
- pocket: OfflineTtsPocketModelConfig {
- lm_main: Some(p("lm_main.int8.onnx")),
- lm_flow: Some(p("lm_flow.int8.onnx")),
- encoder: Some(p("encoder.onnx")),
- decoder: Some(p("decoder.int8.onnx")),
- text_conditioner: Some(p("text_conditioner.onnx")),
- vocab_json: Some(p("vocab.json")),
- token_scores_json: Some(p("token_scores.json")),
- voice_embedding_cache_capacity: 16,
- },
- num_threads: 1,
- debug: false,
- ..Default::default()
- },
- ..Default::default()
- };
- let engine = OfflineTts::create(&cfg).expect("engine create");
-
- let voice_path = dir.join("reference_sample.wav");
- let wave = Wave::read(voice_path.to_str().unwrap()).expect("voice WAV");
- let voice_samples = wave.samples().to_vec();
- let voice_sr = wave.sample_rate();
-
- let gen = || GenerationConfig {
- speed: 1.05,
- num_steps: 1,
- silence_scale: 1.0,
- reference_audio: Some(voice_samples.clone()),
- reference_sample_rate: voice_sr,
- ..Default::default()
- };
-
- let _ = engine.generate_with_config("warmup.", &gen(), None:: bool>);
-
- println!(
- "{:<46} | {:>8} | {:>9} | {:>9} | {:>10}",
- "prompt", "raw peak", "raw RMS", "post-gain", "% clipped"
- );
- println!("{}", "-".repeat(95));
-
- let mut worst_clip = 0.0f32;
- for prompt in PROMPTS {
- let out = engine
- .generate_with_config(prompt, &gen(), None:: bool>)
- .expect("synth");
- let samples = out.samples();
-
- let peak = samples.iter().fold(0.0f32, |m, s| m.max(s.abs()));
- let rms = (samples.iter().map(|s| s * s).sum::() / samples.len() as f32).sqrt();
- let post = peak * GAIN;
- let clipped = samples.iter().filter(|s| s.abs() * GAIN > 1.0).count();
- let clip_pct = 100.0 * clipped as f32 / samples.len() as f32;
- worst_clip = worst_clip.max(clip_pct);
-
- let label: String = prompt.chars().take(44).collect();
- println!("{label:<46} | {peak:>8.4} | {rms:>9.4} | {post:>9.3} | {clip_pct:>9.3}%");
- }
-
- println!();
- println!(
- "Verdict: worst-case clipped fraction {worst_clip:.3}% — {}",
- if worst_clip > 0.1 {
- "AUDIBLE DISTORTION LIKELY (gain too hot)"
- } else if worst_clip > 0.0 {
- "marginal — occasional transient clipping"
- } else {
- "no clipping at this gain"
- }
- );
-}
diff --git a/desktop/src-tauri/examples/pocket_onset_probe.rs b/desktop/src-tauri/examples/pocket_onset_probe.rs
deleted file mode 100644
index 05b4d0193c..0000000000
--- a/desktop/src-tauri/examples/pocket_onset_probe.rs
+++ /dev/null
@@ -1,149 +0,0 @@
-//! Onset-attenuation probe for Pocket TTS.
-//!
-//! Synthesises a handful of short sentences and dumps per-sentence onset
-//! statistics (samples[0], 1ms/5ms/20ms peak + RMS) so we can decide whether
-//! the production `apply_fades` 8 ms fade-in is masking real audio.
-//!
-//! Also writes the raw (un-faded, un-normalised) audio of each sentence to
-//! /tmp so they can be inspected in Audacity / aplay without rodio in the
-//! loop.
-//!
-//! Run with model files in /tmp/pocket-tts-bench (override with arg 1):
-//! cargo run --release --example pocket_onset_probe
-//! cargo run --release --example pocket_onset_probe /path/to/pocket-tts
-
-use std::path::PathBuf;
-
-use sherpa_onnx::{
- self, GenerationConfig, OfflineTts, OfflineTtsConfig, OfflineTtsModelConfig,
- OfflineTtsPocketModelConfig, Wave,
-};
-
-const SAMPLE_RATE: u32 = 24_000;
-
-/// Test prompts chosen to span different onsets:
-/// - palatal glide 'Y' (soft onset)
-/// - voiceless fricative 'H' (very soft onset)
-/// - labio-velar glide 'W' (medium onset)
-/// - voiceless stop 'T' (hard onset)
-const PROMPTS: &[&str] = &[
- "Yep, I can hear you.",
- "Hello there friend.",
- "What can I help with?",
- "Try this experiment now.",
-];
-
-fn main() {
- let model_dir = std::env::args()
- .nth(1)
- .unwrap_or_else(|| "/tmp/pocket-tts-bench".to_string());
- eprintln!("Model dir: {model_dir}");
-
- let dir = PathBuf::from(&model_dir);
- let p = |name: &str| dir.join(name).to_string_lossy().into_owned();
-
- let cfg = OfflineTtsConfig {
- model: OfflineTtsModelConfig {
- pocket: OfflineTtsPocketModelConfig {
- lm_main: Some(p("lm_main.int8.onnx")),
- lm_flow: Some(p("lm_flow.int8.onnx")),
- encoder: Some(p("encoder.onnx")),
- decoder: Some(p("decoder.int8.onnx")),
- text_conditioner: Some(p("text_conditioner.onnx")),
- vocab_json: Some(p("vocab.json")),
- token_scores_json: Some(p("token_scores.json")),
- voice_embedding_cache_capacity: 16,
- },
- num_threads: 1,
- debug: false,
- ..Default::default()
- },
- ..Default::default()
- };
- let engine = OfflineTts::create(&cfg).expect("engine create");
-
- let voice_path = dir.join("reference_sample.wav");
- let wave = Wave::read(voice_path.to_str().unwrap()).expect("voice WAV");
- let voice_samples = wave.samples().to_vec();
- let voice_sr = wave.sample_rate();
-
- // Warmup so we're not measuring cold-call jitter.
- {
- let cfg = GenerationConfig {
- speed: 1.05,
- num_steps: 1,
- silence_scale: 1.0, // production setting (huddle::pocket::SYNTH_SILENCE_SCALE)
- reference_audio: Some(voice_samples.clone()),
- reference_sample_rate: voice_sr,
- ..Default::default()
- };
- let _ = engine.generate_with_config("warmup.", &cfg, None:: bool>);
- }
-
- println!(
- "{:<28} | {:>10} | {:>10} {:>10} | {:>10} {:>10} | {:>10} {:>10}",
- "prompt",
- "samples[0]",
- "peak@1ms",
- "rms@1ms",
- "peak@5ms",
- "rms@5ms",
- "peak@20ms",
- "rms@20ms"
- );
- println!("{}", "-".repeat(120));
-
- for prompt in PROMPTS {
- // Mirror the production prompt-prep (capitalise + terminal punctuation).
- // These prompts already have it, so this is just to match what
- // sherpa-onnx sees in production.
- let cfg = GenerationConfig {
- speed: 1.05,
- num_steps: 1,
- silence_scale: 1.0, // production setting (huddle::pocket::SYNTH_SILENCE_SCALE)
- reference_audio: Some(voice_samples.clone()),
- reference_sample_rate: voice_sr,
- ..Default::default()
- };
- let out = engine
- .generate_with_config(prompt, &cfg, None:: bool>)
- .expect("synth");
- let samples = out.samples();
-
- let n_1ms = (SAMPLE_RATE as f32 * 0.001) as usize;
- let n_5ms = (SAMPLE_RATE as f32 * 0.005) as usize;
- let n_20ms = (SAMPLE_RATE as f32 * 0.020) as usize;
-
- let stats = |range: &[f32]| -> (f32, f32) {
- if range.is_empty() {
- return (0.0, 0.0);
- }
- let peak = range.iter().fold(0.0_f32, |a, &x| a.max(x.abs()));
- let sumsq: f32 = range.iter().map(|x| x * x).sum();
- let rms = (sumsq / range.len() as f32).sqrt();
- (peak, rms)
- };
-
- let first = samples.first().copied().unwrap_or(0.0);
- let (p1, r1) = stats(&samples[..n_1ms.min(samples.len())]);
- let (p5, r5) = stats(&samples[..n_5ms.min(samples.len())]);
- let (p20, r20) = stats(&samples[..n_20ms.min(samples.len())]);
-
- println!(
- "{:<28} | {:>10.6} | {:>10.6} {:>10.6} | {:>10.6} {:>10.6} | {:>10.6} {:>10.6}",
- prompt, first, p1, r1, p5, r5, p20, r20
- );
-
- let safe: String = prompt
- .chars()
- .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
- .collect();
- let out_path = format!("/tmp/pocket_onset_{}.wav", &safe[..safe.len().min(24)]);
- let _ = sherpa_onnx::write(&out_path, samples, SAMPLE_RATE as i32);
- eprintln!(
- " → wrote {out_path} ({} samples = {:.3} s)",
- samples.len(),
- samples.len() as f32 / SAMPLE_RATE as f32
- );
- }
-}
diff --git a/desktop/src-tauri/examples/pocket_quality_ab.rs b/desktop/src-tauri/examples/pocket_quality_ab.rs
deleted file mode 100644
index 0c31f1c910..0000000000
--- a/desktop/src-tauri/examples/pocket_quality_ab.rs
+++ /dev/null
@@ -1,519 +0,0 @@
-//! Reproducible blind Pocket TTS quality corpus generator.
-//!
-//! Renders Buzz's production prompt preparation and post-processing across:
-//! INT8/FP32 × per-sentence/grouped generation. The generated filenames are
-//! deterministically blinded; keep `key.json` away from listeners until their
-//! scoring sheet is complete.
-//!
-//! Usage:
-//! cargo run --release --example pocket_quality_ab -- \
-//! [--idle-minutes N --only ITEM]
-//!
-//! The optional idle run intentionally creates one engine per condition, warms
-//! all four, sleeps once, and then makes each clip the first generation after
-//! dormancy. It requires `--only` because only the first synthesis after an
-//! uninterrupted idle is a valid post-idle observation. Run each 5/15-minute
-//! item as a separate process.
-
-// Importing the production module also brings in runtime-only helpers that this
-// standalone corpus generator deliberately does not call.
-#![allow(dead_code)]
-
-#[path = "../src/huddle/pocket.rs"]
-mod production_pocket;
-#[path = "../src/huddle/preprocessing.rs"]
-mod production_preprocessing;
-
-use std::collections::HashMap;
-use std::fs;
-use std::path::{Path, PathBuf};
-use std::time::{Duration, Instant};
-
-use serde::Serialize;
-use sha2::{Digest, Sha256};
-use sherpa_onnx::{GenerationConfig, OfflineTts, OfflineTtsConfig, Wave};
-
-use production_pocket::{prepare_pocket_prompt, SAMPLE_RATE};
-use production_preprocessing::{preprocess_for_tts, split_sentences};
-
-const NUM_STEPS: i32 = 1;
-const SILENCE_SCALE: f32 = 1.0;
-const INTER_SENTENCE_SILENCE_SAMPLES: usize = SAMPLE_RATE as usize / 10;
-const LEAD_IN_SAMPLES: usize = SAMPLE_RATE as usize / 50;
-const FADE_OUT_SAMPLES: usize = SAMPLE_RATE as usize * 8 / 1000;
-const TARGET_RMS_DBFS: f32 = -23.0;
-const BLINDING_SEED: &str = "pocket-quality-2026-07-21-v1";
-
-const CORPUS: &[CorpusItem] = &[
- CorpusItem { id: "short_one_word", kind: "short", text: "Yep." },
- CorpusItem { id: "short_four_words", kind: "short", text: "Sounds good to me." },
- CorpusItem {
- id: "multi_relay_review",
- kind: "multi-sentence",
- text: "I looked at the relay code this morning. The lease logic is solid. There's one race in the worker claim path, though. I'll write it up and send you a patch.",
- },
- CorpusItem {
- id: "multi_community_size",
- kind: "multi-sentence",
- text: "Great question. The answer is it depends on the community size. For small ones, keep it simple.",
- },
- CorpusItem {
- id: "mixed_agent_message",
- kind: "mixed",
- text: "That's 42 open PRs right now — mostly small. I'll triage them after lunch.",
- },
-];
-
-#[derive(Clone, Copy)]
-struct CorpusItem {
- id: &'static str,
- kind: &'static str,
- text: &'static str,
-}
-
-#[derive(Clone, Copy, Debug, Serialize)]
-#[serde(rename_all = "snake_case")]
-enum Precision {
- Int8,
- Fp32,
-}
-
-#[derive(Clone, Copy, Debug, Serialize)]
-#[serde(rename_all = "snake_case")]
-enum Chunking {
- PerSentence,
- Grouped,
-}
-
-#[derive(Clone, Copy, Debug)]
-struct Condition {
- precision: Precision,
- chunking: Chunking,
-}
-
-const CONDITIONS: [Condition; 4] = [
- Condition {
- precision: Precision::Int8,
- chunking: Chunking::PerSentence,
- },
- Condition {
- precision: Precision::Int8,
- chunking: Chunking::Grouped,
- },
- Condition {
- precision: Precision::Fp32,
- chunking: Chunking::PerSentence,
- },
- Condition {
- precision: Precision::Fp32,
- chunking: Chunking::Grouped,
- },
-];
-
-#[derive(Serialize)]
-struct KeyFile {
- warning: &'static str,
- blinding_seed: &'static str,
- target_rms_dbfs: f32,
- items: Vec,
-}
-
-#[derive(Serialize)]
-struct KeyItem {
- id: String,
- kind: String,
- text: String,
- clips: Vec,
-}
-
-#[derive(Serialize)]
-struct KeyClip {
- file: String,
- precision: Precision,
- chunking: Chunking,
- cold_start: bool,
- idle_minutes: Option,
- synthesis_ms: u128,
- audio_seconds: f32,
-}
-
-struct Voice {
- samples: Vec,
- sample_rate: i32,
-}
-
-struct Engine {
- inner: OfflineTts,
- voice: Voice,
-}
-
-fn main() -> Result<(), String> {
- let mut args = std::env::args().skip(1);
- let int8_dir = required_path(args.next(), "INT8 model directory")?;
- let fp32_dir = required_path(args.next(), "FP32 model directory")?;
- let output_dir = required_path(args.next(), "output directory")?;
- let mut idle_minutes = None;
- let mut only_item = None;
- while let Some(arg) = args.next() {
- match arg.as_str() {
- "--idle-minutes" => {
- idle_minutes = Some(
- args.next()
- .ok_or("--idle-minutes requires a value")?
- .parse::()
- .map_err(|e| format!("invalid idle minutes: {e}"))?,
- );
- }
- "--only" => only_item = Some(args.next().ok_or("--only requires an item ID")?),
- _ => return Err(format!("unknown argument: {arg}")),
- }
- }
-
- if idle_minutes.is_some() && only_item.is_none() {
- return Err("--idle-minutes requires --only so every clip is first-after-idle".into());
- }
- if let Some(ref requested) = only_item {
- if !CORPUS.iter().any(|item| item.id == requested) {
- return Err(format!("unknown corpus item for --only: {requested}"));
- }
- }
-
- validate_model_dir(&int8_dir, Precision::Int8)?;
- validate_model_dir(&fp32_dir, Precision::Fp32)?;
- fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?;
-
- let mut engines = Vec::with_capacity(CONDITIONS.len());
- for condition in CONDITIONS {
- let dir = match condition.precision {
- Precision::Int8 => &int8_dir,
- Precision::Fp32 => &fp32_dir,
- };
- let engine = load_engine(dir, condition.precision)?;
- // Production warms once before serving a real utterance. Cold cases use
- // separate fresh engines below and deliberately skip this call.
- synth_chunks(&engine, &["warmup".to_string()])?;
- engines.push(engine);
- }
-
- if let Some(minutes) = idle_minutes {
- eprintln!("All four warmed engines idle for {minutes} minute(s)…");
- std::thread::sleep(Duration::from_secs(minutes * 60));
- }
-
- let mut key_items = Vec::new();
- for item in CORPUS {
- if only_item
- .as_deref()
- .is_some_and(|requested| requested != item.id)
- {
- continue;
- }
- let preprocessed = preprocess_for_tts(item.text);
- let per_sentence: Vec = split_sentences(&preprocessed)
- .into_iter()
- .filter(|s| !s.trim().is_empty())
- .collect();
- // These corpus texts are deliberately below the upstream ~50-token
- // grouping target, so grouped mode is one exact generate() call.
- let grouped = vec![per_sentence.join(" ")];
- let item_dir = output_dir.join(item.id);
- fs::create_dir_all(&item_dir).map_err(|e| e.to_string())?;
- let clip_order = blinded_order(item.id);
- let mut clips = Vec::new();
-
- let mut rendered = Vec::new();
- for (condition_index, engine) in engines.iter().enumerate() {
- let condition = CONDITIONS[condition_index];
- let chunks = match condition.chunking {
- Chunking::PerSentence => &per_sentence,
- Chunking::Grouped => &grouped,
- };
- let started = Instant::now();
- let audio = synth_chunks(engine, chunks)?;
- rendered.push((
- condition_index,
- condition,
- audio,
- started.elapsed().as_millis(),
- ));
- }
- loudness_match_item(&mut rendered);
- for (condition_index, condition, audio, synth_ms) in rendered {
- let clip_number = clip_order[condition_index] + 1;
- let file_name = format!("clip{clip_number}.wav");
- write_wav(&item_dir.join(&file_name), &audio)?;
- clips.push(KeyClip {
- file: format!("{}/{file_name}", item.id),
- precision: condition.precision,
- chunking: condition.chunking,
- cold_start: false,
- idle_minutes,
- synthesis_ms: synth_ms,
- audio_seconds: audio.len() as f32 / SAMPLE_RATE as f32,
- });
- }
- clips.sort_by(|a, b| a.file.cmp(&b.file));
- key_items.push(KeyItem {
- id: item.id.to_string(),
- kind: item.kind.to_string(),
- text: item.text.to_string(),
- clips,
- });
- }
-
- // Explicit fresh-engine cold-start clips for the two highest-signal texts.
- // Idle runs intentionally omit them: they happen after the post-idle clips
- // and add no valid idle observation.
- for item in if idle_minutes.is_none() { CORPUS } else { &[] } {
- if !matches!(item.id, "short_one_word" | "multi_relay_review") {
- continue;
- }
- if only_item
- .as_deref()
- .is_some_and(|requested| requested != item.id)
- {
- continue;
- }
- let cold_id = format!("cold_{}", item.id);
- let preprocessed = preprocess_for_tts(item.text);
- let sentences: Vec = split_sentences(&preprocessed)
- .into_iter()
- .filter(|s| !s.trim().is_empty())
- .collect();
- let grouped = vec![sentences.join(" ")];
- let item_dir = output_dir.join(&cold_id);
- fs::create_dir_all(&item_dir).map_err(|e| e.to_string())?;
- let clip_order = blinded_order(&cold_id);
- let mut clips = Vec::new();
- let mut rendered = Vec::new();
- for (condition_index, condition) in CONDITIONS.iter().copied().enumerate() {
- let dir = match condition.precision {
- Precision::Int8 => &int8_dir,
- Precision::Fp32 => &fp32_dir,
- };
- let engine = load_engine(dir, condition.precision)?;
- let chunks = match condition.chunking {
- Chunking::PerSentence => &sentences,
- Chunking::Grouped => &grouped,
- };
- let started = Instant::now();
- let audio = synth_chunks(&engine, chunks)?;
- rendered.push((
- condition_index,
- condition,
- audio,
- started.elapsed().as_millis(),
- ));
- }
- loudness_match_item(&mut rendered);
- for (condition_index, condition, audio, synth_ms) in rendered {
- let clip_number = clip_order[condition_index] + 1;
- let file_name = format!("clip{clip_number}.wav");
- write_wav(&item_dir.join(&file_name), &audio)?;
- clips.push(KeyClip {
- file: format!("{cold_id}/{file_name}"),
- precision: condition.precision,
- chunking: condition.chunking,
- cold_start: true,
- idle_minutes: None,
- synthesis_ms: synth_ms,
- audio_seconds: audio.len() as f32 / SAMPLE_RATE as f32,
- });
- }
- clips.sort_by(|a, b| a.file.cmp(&b.file));
- key_items.push(KeyItem {
- id: cold_id,
- kind: "cold-start".to_string(),
- text: item.text.to_string(),
- clips,
- });
- }
-
- let key = KeyFile {
- warning: "DO NOT OPEN UNTIL LISTENING SCORES ARE FINAL",
- blinding_seed: BLINDING_SEED,
- target_rms_dbfs: TARGET_RMS_DBFS,
- items: key_items,
- };
- fs::write(
- output_dir.join("key.json"),
- serde_json::to_vec_pretty(&key).map_err(|e| e.to_string())?,
- )
- .map_err(|e| e.to_string())?;
- write_scoring_sheet(&output_dir, &key)?;
- println!("Wrote blind corpus to {}", output_dir.display());
- println!("Give listeners the WAV folders and SCORING.md; withhold key.json.");
- Ok(())
-}
-
-fn required_path(value: Option, label: &str) -> Result {
- value
- .map(PathBuf::from)
- .ok_or_else(|| format!("missing {label}"))
-}
-
-fn model_file(precision: Precision, base: &str) -> String {
- match precision {
- Precision::Int8 => format!("{base}.int8.onnx"),
- Precision::Fp32 => format!("{base}.onnx"),
- }
-}
-
-fn validate_model_dir(dir: &Path, precision: Precision) -> Result<(), String> {
- for file in [
- model_file(precision, "lm_main"),
- model_file(precision, "lm_flow"),
- "encoder.onnx".into(),
- model_file(precision, "decoder"),
- "text_conditioner.onnx".into(),
- "vocab.json".into(),
- "token_scores.json".into(),
- "reference_sample.wav".into(),
- ] {
- if !dir.join(&file).is_file() {
- return Err(format!("missing {}", dir.join(file).display()));
- }
- }
- Ok(())
-}
-
-fn load_engine(dir: &Path, precision: Precision) -> Result {
- let p = |name: &str| dir.join(name).to_string_lossy().into_owned();
- let mut cfg = OfflineTtsConfig::default();
- cfg.model.pocket.lm_main = Some(p(&model_file(precision, "lm_main")));
- cfg.model.pocket.lm_flow = Some(p(&model_file(precision, "lm_flow")));
- cfg.model.pocket.encoder = Some(p("encoder.onnx"));
- cfg.model.pocket.decoder = Some(p(&model_file(precision, "decoder")));
- cfg.model.pocket.text_conditioner = Some(p("text_conditioner.onnx"));
- cfg.model.pocket.vocab_json = Some(p("vocab.json"));
- cfg.model.pocket.token_scores_json = Some(p("token_scores.json"));
- cfg.model.pocket.voice_embedding_cache_capacity = 16;
- cfg.model.num_threads = 1;
- cfg.model.debug = false;
- let inner =
- OfflineTts::create(&cfg).ok_or_else(|| format!("failed to create {precision:?} engine"))?;
- let wave =
- Wave::read(&p("reference_sample.wav")).ok_or("failed to read reference_sample.wav")?;
- Ok(Engine {
- inner,
- voice: Voice {
- samples: wave.samples().to_vec(),
- sample_rate: wave.sample_rate(),
- },
- })
-}
-
-fn synth_chunks(engine: &Engine, chunks: &[String]) -> Result, String> {
- let mut out = Vec::new();
- for chunk in chunks {
- let prepared = prepare_pocket_prompt(chunk).ok_or("empty prepared prompt")?;
- let extra = prepared.max_frames.map(|max_frames| {
- HashMap::from([(
- "max_frames".to_string(),
- serde_json::Value::from(max_frames),
- )])
- });
- let cfg = GenerationConfig {
- num_steps: NUM_STEPS,
- silence_scale: SILENCE_SCALE,
- reference_audio: Some(engine.voice.samples.clone()),
- reference_sample_rate: engine.voice.sample_rate,
- extra,
- ..Default::default()
- };
- let audio = engine
- .inner
- .generate_with_config(&prepared.text, &cfg, None:: bool>)
- .ok_or_else(|| format!("synthesis failed for {chunk:?}"))?;
- let mut samples: Vec = audio.samples().iter().map(|s| s.clamp(-1.0, 1.0)).collect();
- apply_fade_out(&mut samples);
- out.extend(std::iter::repeat_n(0.0, LEAD_IN_SAMPLES));
- out.extend(samples);
- out.extend(std::iter::repeat_n(
- 0.0,
- INTER_SENTENCE_SILENCE_SAMPLES - LEAD_IN_SAMPLES,
- ));
- }
- Ok(out)
-}
-
-fn apply_fade_out(samples: &mut [f32]) {
- let fade = FADE_OUT_SAMPLES.min(samples.len() / 2);
- for i in 0..fade {
- samples[samples.len() - 1 - i] *= i as f32 / fade as f32;
- }
-}
-
-fn active_rms(samples: &[f32]) -> Option {
- let (sum_squares, count) = samples
- .iter()
- .filter(|sample| sample.abs() > 1.0e-4)
- .fold((0.0_f32, 0_usize), |(sum, count), sample| {
- (sum + sample * sample, count + 1)
- });
- (count > 0).then(|| (sum_squares / count as f32).sqrt())
-}
-
-/// Attenuate every clip in one comparison set to the quietest active-speech RMS.
-/// This removes the louder-is-better confound without normalizing dynamics or
-/// claiming standards-compliant integrated LUFS. The dBFS value is a ceiling.
-fn loudness_match_item(rendered: &mut [(usize, Condition, Vec, u128)]) {
- let ceiling = 10.0_f32.powf(TARGET_RMS_DBFS / 20.0);
- let target = rendered
- .iter()
- .filter_map(|(_, _, samples, _)| active_rms(samples))
- .fold(ceiling, f32::min);
- for (_, _, samples, _) in rendered {
- let Some(rms) = active_rms(samples) else {
- continue;
- };
- let gain = (target / rms).min(1.0);
- for sample in samples {
- *sample *= gain;
- }
- }
-}
-
-fn blinded_order(item_id: &str) -> [usize; 4] {
- let mut keyed: Vec<(usize, Vec)> = (0..4)
- .map(|index| {
- let digest = Sha256::digest(format!("{BLINDING_SEED}:{item_id}:{index}"));
- (index, digest.to_vec())
- })
- .collect();
- keyed.sort_by(|a, b| a.1.cmp(&b.1));
- let mut condition_to_clip = [0; 4];
- for (clip, (condition, _)) in keyed.into_iter().enumerate() {
- condition_to_clip[condition] = clip;
- }
- condition_to_clip
-}
-
-fn write_wav(path: &Path, samples: &[f32]) -> Result<(), String> {
- let path = path
- .to_str()
- .ok_or_else(|| format!("non-UTF8 path: {}", path.display()))?;
- if sherpa_onnx::write(path, samples, SAMPLE_RATE as i32) {
- Ok(())
- } else {
- Err(format!("failed to write {path}"))
- }
-}
-
-fn write_scoring_sheet(output_dir: &Path, key: &KeyFile) -> Result<(), String> {
- let mut sheet = String::from("# Pocket TTS blind listening sheet\n\nDo not open `key.json` until this sheet is complete. Rank best to worst; ties are allowed.\n\n");
- for item in &key.items {
- sheet.push_str(&format!(
- "## {} ({})\n\n> {}\n\n",
- item.id, item.kind, item.text
- ));
- sheet.push_str("Rank: `____ > ____ > ____ > ____`\n\n| Clip | seam | onset | garble | robotic | timbre | truncate | note |\n|---|---|---|---|---|---|---|---|\n");
- for clip in 1..=4 {
- sheet.push_str(&format!(
- "| clip{clip} | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | |\n"
- ));
- }
- sheet.push('\n');
- }
- fs::write(output_dir.join("SCORING.md"), sheet).map_err(|e| e.to_string())
-}
diff --git a/desktop/src-tauri/resources/pocket-voices/NOTICE.md b/desktop/src-tauri/resources/pocket-voices/NOTICE.md
new file mode 100644
index 0000000000..9cc515dea3
--- /dev/null
+++ b/desktop/src-tauri/resources/pocket-voices/NOTICE.md
@@ -0,0 +1,35 @@
+# Pocket TTS English VCTK presets
+
+Buzz exposes Kyutai's twelve official English VCTK Pocket presets. The WAV
+bytes are unchanged from `kyutai/tts-voices` revision
+`323332d33f997de8394f24a193e1a76df720e01a`; only local filenames differ.
+
+| Voice | Upstream asset | SHA-256 |
+| --- | --- | --- |
+| Anna | `vctk/p228_023_enhanced.wav` | `0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856` |
+| Vera | `vctk/p229_023_enhanced.wav` | `309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b` |
+| Fantine | `vctk/p244_023_enhanced.wav` | `5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b` |
+| Charles | `vctk/p254_023_enhanced.wav` | `6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756` |
+| Paul | `vctk/p259_023_enhanced.wav` | `7aba504fe0b3b16478b69eb27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b` |
+| Eponine | `vctk/p262_023_enhanced.wav` | `a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b` |
+| Azelma | `vctk/p303_023_enhanced.wav` | `60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026` |
+| George | `vctk/p315_023_enhanced.wav` | `29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae` |
+| Mary | `vctk/p333_023_enhanced.wav` | `a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f` |
+| Jane | `vctk/p339_023_enhanced.wav` | `2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a` |
+| Michael | `vctk/p360_023_enhanced.wav` | `b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad` |
+| Eve | `vctk/p361_023_enhanced.wav` | `396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd` |
+
+Mary is already installed as the Pocket model's `reference_sample.wav`, so it
+is not duplicated in this resource directory.
+
+Source repository:
+https://huggingface.co/kyutai/tts-voices/tree/323332d33f997de8394f24a193e1a76df720e01a/vctk
+
+The original recordings are from the Voice Cloning Toolkit (VCTK) corpus,
+licensed CC BY 4.0:
+https://datashare.ed.ac.uk/handle/10283/3443
+
+The recordings were enhanced by ai-coustics:
+https://ai-coustics.com/
+
+Neither Kyutai, the VCTK speakers, nor ai-coustics endorses Buzz.
diff --git a/desktop/src-tauri/resources/pocket-voices/anna.wav b/desktop/src-tauri/resources/pocket-voices/anna.wav
new file mode 100644
index 0000000000..79d60697ff
Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/anna.wav differ
diff --git a/desktop/src-tauri/resources/pocket-voices/azelma.wav b/desktop/src-tauri/resources/pocket-voices/azelma.wav
new file mode 100644
index 0000000000..e9d0c00b3f
Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/azelma.wav differ
diff --git a/desktop/src-tauri/resources/pocket-voices/charles.wav b/desktop/src-tauri/resources/pocket-voices/charles.wav
new file mode 100644
index 0000000000..2170975545
Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/charles.wav differ
diff --git a/desktop/src-tauri/resources/pocket-voices/eponine.wav b/desktop/src-tauri/resources/pocket-voices/eponine.wav
new file mode 100644
index 0000000000..bded6f4f09
Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/eponine.wav differ
diff --git a/desktop/src-tauri/resources/pocket-voices/eve.wav b/desktop/src-tauri/resources/pocket-voices/eve.wav
new file mode 100644
index 0000000000..216665ff13
Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/eve.wav differ
diff --git a/desktop/src-tauri/resources/pocket-voices/fantine.wav b/desktop/src-tauri/resources/pocket-voices/fantine.wav
new file mode 100644
index 0000000000..28c2b1140d
Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/fantine.wav differ
diff --git a/desktop/src-tauri/resources/pocket-voices/george.wav b/desktop/src-tauri/resources/pocket-voices/george.wav
new file mode 100644
index 0000000000..739d5bc7a5
Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/george.wav differ
diff --git a/desktop/src-tauri/resources/pocket-voices/jane.wav b/desktop/src-tauri/resources/pocket-voices/jane.wav
new file mode 100644
index 0000000000..3c9890473b
Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/jane.wav differ
diff --git a/desktop/src-tauri/resources/pocket-voices/michael.wav b/desktop/src-tauri/resources/pocket-voices/michael.wav
new file mode 100644
index 0000000000..861da085c7
Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/michael.wav differ
diff --git a/desktop/src-tauri/resources/pocket-voices/paul.wav b/desktop/src-tauri/resources/pocket-voices/paul.wav
new file mode 100644
index 0000000000..bfde50fdd9
Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/paul.wav differ
diff --git a/desktop/src-tauri/resources/pocket-voices/vera.wav b/desktop/src-tauri/resources/pocket-voices/vera.wav
new file mode 100644
index 0000000000..e4fce84ce3
Binary files /dev/null and b/desktop/src-tauri/resources/pocket-voices/vera.wav differ
diff --git a/desktop/src-tauri/src/app_protocols.rs b/desktop/src-tauri/src/app_protocols.rs
new file mode 100644
index 0000000000..de76bbfa37
--- /dev/null
+++ b/desktop/src-tauri/src/app_protocols.rs
@@ -0,0 +1,25 @@
+use tauri::{Builder, Wry};
+
+use crate::{commands::*, media_proxy};
+
+pub fn register(builder: Builder) -> Builder {
+ let builder = builder.register_asynchronous_uri_scheme_protocol(
+ "buzz-media",
+ |ctx, request, responder| {
+ let app = ctx.app_handle().clone();
+ tauri::async_runtime::spawn(async move {
+ let response = media_proxy::handle_buzz_media(&app, &request).await;
+ responder.respond(response);
+ });
+ },
+ );
+ #[cfg(not(target_os = "windows"))]
+ let builder = builder.register_asynchronous_uri_scheme_protocol(
+ "buzz-mcp-app",
+ |ctx, request, responder| {
+ responder.respond(handle_mcp_app_protocol(ctx.app_handle(), &request));
+ },
+ );
+
+ builder.manage(McpAppHostState::default())
+}
diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs
index abce86202a..fc90e6ab14 100644
--- a/desktop/src-tauri/src/app_state.rs
+++ b/desktop/src-tauri/src/app_state.rs
@@ -53,15 +53,13 @@ pub struct AppState {
pub channel_templates_store_lock: Mutex<()>,
pub managed_agent_processes: Mutex>,
pub huddle_state: Mutex,
+ pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState,
/// Tauri app handle — stored after setup so huddle commands can emit
/// `huddle-state-changed` events without needing the handle threaded
/// through every call site.
///
/// Set once during `setup()` in `lib.rs`; never cleared.
pub app_handle: Mutex>,
- /// Selected audio output device name. `None` = system default.
- /// Used by `connect_audio_relay` and TTS pipeline when opening sinks.
- pub audio_output_device: Mutex >,
/// Port of the localhost media streaming proxy (set during setup).
pub media_proxy_port: AtomicU16,
/// Set when identity resolution detected a "keyring-locked" state: the
@@ -219,8 +217,8 @@ pub fn build_app_state() -> AppState {
managed_agent_processes: Mutex::new(HashMap::new()),
session_config_cache: Mutex::new(HashMap::new()),
huddle_state: Mutex::new(HuddleState::default()),
+ huddle_audio: Default::default(),
app_handle: Mutex::new(None),
- audio_output_device: Mutex::new(None),
media_proxy_port: AtomicU16::new(0),
prevent_sleep: Arc::new(Mutex::new(
crate::prevent_sleep::PreventSleepState::default(),
diff --git a/desktop/src-tauri/src/commands/mcp_apps.rs b/desktop/src-tauri/src/commands/mcp_apps.rs
new file mode 100644
index 0000000000..52e640e3c1
--- /dev/null
+++ b/desktop/src-tauri/src/commands/mcp_apps.rs
@@ -0,0 +1,889 @@
+//! MCP Apps host transport and sandbox registration.
+//!
+//! The webview never receives MCP credentials or a general-purpose network
+//! primitive. Rust owns the reviewed server connection and exposes only the
+//! MCP methods required by the Apps protocol.
+
+use std::{
+ collections::HashMap,
+ net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
+ sync::{
+ atomic::{AtomicU64, Ordering},
+ Arc, Mutex,
+ },
+ time::Duration,
+};
+
+use base64::Engine;
+use futures_util::StreamExt;
+use reqwest::{Client, Response};
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use tauri::{http, AppHandle, Manager, State};
+use tokio::sync::Mutex as AsyncMutex;
+use url::Url;
+use uuid::Uuid;
+
+const MCP_APP_MIME_TYPE: &str = "text/html;profile=mcp-app";
+/// Legacy MCP revision: `initialize` handshake plus `mcp-session-id` sessions.
+const MCP_PROTOCOL_VERSION: &str = "2025-11-25";
+/// Modern MCP revision: sessionless, handshake-free, header-routed requests.
+const MCP_MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
+/// Error codes introduced by the modern (2026-07-28) revision. Used only to
+/// classify a server's era. Deliberately excludes the generic JSON-RPC
+/// `-32602` (Invalid params), which a legacy server may also return and which
+/// would otherwise misclassify it as modern and skip the handshake fallback.
+const MODERN_MCP_ERROR_CODES: [i64; 3] = [-32020, -32021, -32022];
+const MAX_MCP_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
+const MAX_MCP_ERROR_RESPONSE_BYTES: usize = 64 * 1024;
+const MAX_MCP_ERROR_MESSAGE_CHARS: usize = 256;
+const MAX_MCP_APP_HTML_BYTES: usize = 4 * 1024 * 1024;
+const MAX_SERVERS: usize = 16;
+const MAX_VIEWS: usize = 32;
+const MAX_TOOLS: usize = 256;
+const MAX_RESOURCES: usize = 256;
+const IPV4_TRANSLATED_PREFIX: [u8; 12] = [0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0];
+
+const SANDBOX_PROXY_HTML: &str = include_str!("mcp_apps_sandbox_proxy.html");
+
+#[path = "mcp_apps_model.rs"]
+mod model;
+use model::*;
+pub use model::{
+ McpAppHostState, McpAppInvocationContext, McpAppResource, McpAppResourceCsp,
+ McpAppResourcePermissions, McpAppResourcePolicy, McpAppServerDescriptor, McpAppTool,
+ McpAppToolCaller, PreparedMcpAppView,
+};
+
+#[path = "mcp_apps_policy.rs"]
+mod policy;
+use policy::*;
+
+fn is_private_ipv4(ip: Ipv4Addr) -> bool {
+ let octets = ip.octets();
+ ip.is_private()
+ || ip.is_loopback()
+ || ip.is_link_local()
+ || ip.is_broadcast()
+ || ip.is_documentation()
+ || ip.is_unspecified()
+ || ip.is_multicast()
+ || octets[0] == 0
+ // CGNAT, reserved, benchmarking, and IETF protocol assignments.
+ || (octets[0] == 100 && (64..=127).contains(&octets[1]))
+ || octets[0] >= 240
+ || (octets[0] == 198 && (octets[1] & 0xfe) == 18)
+ || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0)
+}
+
+fn embedded_ipv4(ip: Ipv6Addr, prefix: &[u8; 12]) -> Option {
+ let octets = ip.octets();
+ octets
+ .starts_with(prefix)
+ .then(|| Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]))
+}
+
+fn is_private_ipv6(ip: Ipv6Addr) -> bool {
+ if let Some(embedded) = ip.to_ipv4() {
+ return is_private_ipv4(embedded);
+ }
+ if let Some(translated) = embedded_ipv4(ip, &IPV4_TRANSLATED_PREFIX) {
+ return is_private_ipv4(translated);
+ }
+ let segments = ip.segments();
+ ip.is_loopback()
+ || ip.is_unspecified()
+ || ip.is_multicast()
+ || ip.is_unique_local()
+ || ip.is_unicast_link_local()
+ // Discard-only, translation, transition, benchmarking, and documentation ranges.
+ || (segments[0] == 0x0100 && segments[1..4] == [0, 0, 0])
+ || (segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2..6] == [0, 0, 0, 0])
+ || (segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2] == 1)
+ || segments[0] == 0x2002
+ || (segments[0] == 0x2001 && segments[1] == 0)
+ || (segments[0] == 0x2001 && segments[1] == 2 && segments[2] == 0)
+ || (segments[0] == 0x2001 && segments[1] == 0x0db8)
+ || (segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
+}
+
+fn is_private_ip(ip: IpAddr) -> bool {
+ match ip {
+ IpAddr::V4(ip) => is_private_ipv4(ip),
+ IpAddr::V6(ip) => is_private_ipv6(ip),
+ }
+}
+
+fn validate_mcp_endpoint(raw: &str) -> Result {
+ let url = Url::parse(raw).map_err(|error| format!("invalid MCP server URL: {error}"))?;
+ if !url.username().is_empty() || url.password().is_some() {
+ return Err("MCP server URL must not include credentials".to_string());
+ }
+ if url.fragment().is_some() {
+ return Err("MCP server URL must not include a fragment".to_string());
+ }
+ let host = url
+ .host_str()
+ .ok_or_else(|| "MCP server URL is missing a host".to_string())?;
+ let loopback_host = host.eq_ignore_ascii_case("localhost")
+ || host
+ .parse::()
+ .is_ok_and(|address| address.is_loopback());
+ match url.scheme() {
+ "https" => {}
+ "http" if loopback_host => {}
+ _ => {
+ return Err(
+ "MCP servers require HTTPS; HTTP is allowed only for loopback development"
+ .to_string(),
+ )
+ }
+ }
+ if host.ends_with(".local") || host.contains('%') {
+ return Err("MCP server host is not allowed".to_string());
+ }
+ Ok(url)
+}
+
+async fn build_pinned_client(endpoint: &Url) -> Result {
+ let host = endpoint
+ .host_str()
+ .ok_or_else(|| "MCP server URL is missing a host".to_string())?;
+ let port = endpoint
+ .port_or_known_default()
+ .ok_or_else(|| "MCP server URL is missing a port".to_string())?;
+ let loopback = host.eq_ignore_ascii_case("localhost")
+ || host
+ .parse::()
+ .is_ok_and(|address| address.is_loopback());
+ let addresses = tokio::net::lookup_host((host, port))
+ .await
+ .map_err(|error| format!("failed to resolve MCP server: {error}"))?
+ .collect::>();
+ if addresses.is_empty() {
+ return Err("MCP server did not resolve to an address".to_string());
+ }
+ if loopback {
+ if addresses.iter().any(|address| !address.ip().is_loopback()) {
+ return Err("loopback MCP server resolved outside loopback".to_string());
+ }
+ } else if addresses.iter().any(|address| is_private_ip(address.ip())) {
+ return Err("MCP server resolved to a private or reserved address".to_string());
+ }
+ let mut builder = Client::builder()
+ .no_proxy()
+ .redirect(reqwest::redirect::Policy::none())
+ .connect_timeout(Duration::from_secs(10))
+ .timeout(Duration::from_secs(120))
+ .pool_idle_timeout(Duration::from_secs(30))
+ .pool_max_idle_per_host(2);
+ if host.parse::().is_err() {
+ builder = builder.resolve_to_addrs(host, &addresses);
+ }
+ builder
+ .build()
+ .map_err(|error| format!("failed to build MCP HTTP client: {error}"))
+}
+
+#[cfg(target_os = "windows")]
+fn ensure_mcp_apps_supported() -> Result<(), String> {
+ Err("MCP Apps are not available on Windows in this version of Buzz".to_string())
+}
+
+#[cfg(not(target_os = "windows"))]
+fn ensure_mcp_apps_supported() -> Result<(), String> {
+ Ok(())
+}
+
+fn sse_event_end(bytes: &[u8]) -> Option<(usize, usize)> {
+ let lf = bytes
+ .windows(2)
+ .position(|window| window == b"\n\n")
+ .map(|index| (index, index + 2));
+ let crlf = bytes
+ .windows(4)
+ .position(|window| window == b"\r\n\r\n")
+ .map(|index| (index, index + 4));
+ match (lf, crlf) {
+ (Some(lf), Some(crlf)) => Some(if lf.0 <= crlf.0 { lf } else { crlf }),
+ (Some(found), None) | (None, Some(found)) => Some(found),
+ (None, None) => None,
+ }
+}
+
+fn sse_event_value(event: &[u8]) -> Result, String> {
+ let text = std::str::from_utf8(event).map_err(|_| "MCP event stream is not valid UTF-8")?;
+ let data = text
+ .lines()
+ .filter_map(|line| line.strip_prefix("data:"))
+ .map(|line| line.strip_prefix(' ').unwrap_or(line))
+ .collect::>()
+ .join("\n");
+ if data.is_empty() {
+ return Ok(None);
+ }
+ Ok(serde_json::from_str::(&data).ok())
+}
+
+fn response_matches_id(value: &Value, expected_id: u64) -> bool {
+ value.get("id").and_then(Value::as_u64) == Some(expected_id)
+}
+
+fn take_matching_sse_value(
+ pending: &mut Vec,
+ expected_id: Option,
+ require_match: bool,
+) -> Result, String> {
+ while let Some((event_end, consumed)) = sse_event_end(pending) {
+ let event = pending.drain(..consumed).collect::>();
+ let Some(value) = sse_event_value(&event[..event_end])? else {
+ continue;
+ };
+ if !require_match
+ || expected_id.is_none_or(|expected| response_matches_id(&value, expected))
+ {
+ return Ok(Some(value));
+ }
+ }
+ Ok(None)
+}
+
+async fn read_capped_reply(
+ response: Response,
+ expected_id: Option,
+) -> Result {
+ let status = response.status();
+ let max_bytes = if status.is_success() {
+ MAX_MCP_RESPONSE_BYTES
+ } else {
+ MAX_MCP_ERROR_RESPONSE_BYTES
+ };
+ let limit_message = if status.is_success() {
+ "MCP response exceeds the 4 MiB limit"
+ } else {
+ "MCP error response exceeds the 64 KiB limit"
+ };
+ let headers = response.headers().clone();
+ let session_id = headers
+ .get("mcp-session-id")
+ .and_then(|value| value.to_str().ok())
+ .map(ToOwned::to_owned);
+ if status == reqwest::StatusCode::ACCEPTED {
+ return Ok(McpHttpReply {
+ status,
+ value: None,
+ session_id,
+ });
+ }
+ if response
+ .content_length()
+ .is_some_and(|length| length > max_bytes as u64)
+ {
+ return Err(limit_message.to_string());
+ }
+ let content_type = headers
+ .get(reqwest::header::CONTENT_TYPE)
+ .and_then(|value| value.to_str().ok())
+ .unwrap_or("")
+ .to_ascii_lowercase();
+ let value = if content_type.starts_with("text/event-stream") {
+ let mut received = 0usize;
+ let mut pending = Vec::new();
+ let mut stream = response.bytes_stream();
+ let mut matched = None;
+ while let Some(chunk) = stream.next().await {
+ let chunk = chunk.map_err(|error| format!("failed to read MCP response: {error}"))?;
+ received = received.saturating_add(chunk.len());
+ if received > max_bytes {
+ return Err(limit_message.to_string());
+ }
+ pending.extend_from_slice(&chunk);
+ matched = take_matching_sse_value(&mut pending, expected_id, status.is_success())?;
+ if matched.is_some() {
+ break;
+ }
+ }
+ if matched.is_none() && !pending.is_empty() {
+ if let Some(value) = sse_event_value(&pending)? {
+ if !status.is_success()
+ || expected_id.is_none_or(|expected| response_matches_id(&value, expected))
+ {
+ matched = Some(value);
+ }
+ }
+ }
+ if matched.is_none() && status.is_success() {
+ return Err(
+ "MCP event stream did not contain the matching JSON-RPC response".to_string(),
+ );
+ }
+ matched
+ } else {
+ let mut bytes = Vec::new();
+ let mut stream = response.bytes_stream();
+ while let Some(chunk) = stream.next().await {
+ let chunk = chunk.map_err(|error| format!("failed to read MCP response: {error}"))?;
+ if bytes.len().saturating_add(chunk.len()) > max_bytes {
+ return Err(limit_message.to_string());
+ }
+ bytes.extend_from_slice(&chunk);
+ }
+ match serde_json::from_slice::(&bytes) {
+ Ok(value)
+ if status.is_success()
+ && expected_id
+ .is_some_and(|expected| !response_matches_id(&value, expected)) =>
+ {
+ return Err("MCP response JSON-RPC id does not match the request".to_string())
+ }
+ Ok(value) => Some(value),
+ Err(error) if status.is_success() => {
+ return Err(format!("MCP response is not valid JSON: {error}"))
+ }
+ Err(_) => None,
+ }
+ };
+ Ok(McpHttpReply {
+ status,
+ value,
+ session_id,
+ })
+}
+
+fn format_rpc_error(error: &Value) -> String {
+ let code = error
+ .get("code")
+ .and_then(Value::as_i64)
+ .map(|code| code.to_string())
+ .unwrap_or_else(|| "unknown".to_string());
+ let message = error
+ .get("message")
+ .and_then(Value::as_str)
+ .unwrap_or("The server returned an MCP error.");
+ let mut truncated = message
+ .chars()
+ .take(MAX_MCP_ERROR_MESSAGE_CHARS)
+ .collect::();
+ if message.chars().count() > MAX_MCP_ERROR_MESSAGE_CHARS {
+ truncated.push('…');
+ }
+ format!("code {code}: {truncated}")
+}
+
+/// Convert a raw reply into the success-only wire response, preserving the
+/// pre-dual-era error messages.
+fn wire_response(reply: McpHttpReply) -> Result {
+ if !reply.status.is_success() {
+ return Err(format!("MCP server returned HTTP {}", reply.status));
+ }
+ if let Some(error) = reply.value.as_ref().and_then(|value| value.get("error")) {
+ return Err(format!("MCP request failed: {}", format_rpc_error(error)));
+ }
+ Ok(McpWireResponse {
+ value: reply.value,
+ session_id: reply.session_id,
+ })
+}
+
+/// The modern `_meta` block every modern-era request body must carry:
+/// protocol version (must match the `MCP-Protocol-Version` header), client
+/// capabilities with the Apps UI extension, and client info.
+fn modern_meta(protocol_version: &str) -> Value {
+ json!({
+ "io.modelcontextprotocol/protocolVersion": protocol_version,
+ "io.modelcontextprotocol/clientCapabilities": {
+ "extensions": {
+ "io.modelcontextprotocol/ui": {
+ "mimeTypes": [MCP_APP_MIME_TYPE]
+ }
+ }
+ },
+ "io.modelcontextprotocol/clientInfo": {
+ "name": "Buzz Desktop",
+ "version": env!("CARGO_PKG_VERSION")
+ }
+ })
+}
+
+/// Inject the modern `_meta` block into a request's params, preserving any
+/// caller-provided `_meta` entries that do not collide with the required keys.
+fn inject_modern_meta(params: Value, protocol_version: &str) -> Value {
+ let meta = modern_meta(protocol_version);
+ let Value::Object(mut map) = params else {
+ return json!({ "_meta": meta });
+ };
+ let mut merged = match map.remove("_meta") {
+ Some(Value::Object(existing)) => existing,
+ _ => serde_json::Map::new(),
+ };
+ if let Value::Object(entries) = meta {
+ for (key, value) in entries {
+ merged.insert(key, value);
+ }
+ }
+ map.insert("_meta".to_string(), Value::Object(merged));
+ Value::Object(map)
+}
+
+/// Prepare request params for one era: modern requests carry the required
+/// `_meta` block, legacy requests pass through untouched.
+fn prepare_params(era: McpEra, params: Value, protocol_version: &str) -> Value {
+ match era {
+ McpEra::Modern => inject_modern_meta(params, protocol_version),
+ McpEra::Legacy => params,
+ }
+}
+
+/// The `Mcp-Name` value the modern revision requires for name-addressed
+/// methods; `None` for every other method.
+fn modern_mcp_name(method: &str, params: &Value) -> Option {
+ match method {
+ "tools/call" | "prompts/get" => text(params.get("name")),
+ "resources/read" => text(params.get("uri")),
+ _ => None,
+ }
+}
+
+/// Encode an `Mcp-Name` header value. Plain visible-ASCII values pass through;
+/// anything else (non-ASCII, whitespace, or a value that could be mistaken for
+/// the sentinel itself) uses the spec's `=?base64?{value}?=` sentinel form.
+fn mcp_name_header_value(raw: &str) -> String {
+ let plain = !raw.is_empty()
+ && !raw.starts_with("=?")
+ && raw.bytes().all(|byte| (0x21..=0x7e).contains(&byte));
+ if plain {
+ raw.to_string()
+ } else {
+ format!(
+ "=?base64?{}?=",
+ base64::engine::general_purpose::STANDARD.encode(raw.as_bytes())
+ )
+ }
+}
+
+fn mcp_param_header_value(value: &Value) -> Result, String> {
+ let raw = match value {
+ Value::Null => return Ok(None),
+ Value::String(value) => value.clone(),
+ Value::Bool(value) => value.to_string(),
+ Value::Number(value) => {
+ const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
+ let integer = value
+ .as_i64()
+ .ok_or_else(|| "x-mcp-header parameter must be an integer".to_string())?;
+ if !(-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&integer) {
+ return Err(
+ "x-mcp-header integer exceeds the JavaScript safe integer range".to_string(),
+ );
+ }
+ integer.to_string()
+ }
+ _ => {
+ return Err(
+ "x-mcp-header parameter must be a string, integer, boolean, or null".to_string(),
+ )
+ }
+ };
+ let sentinel = raw.starts_with("=?");
+ let edge_whitespace = raw
+ .as_bytes()
+ .first()
+ .is_some_and(|byte| matches!(byte, b' ' | b'\t'))
+ || raw
+ .as_bytes()
+ .last()
+ .is_some_and(|byte| matches!(byte, b' ' | b'\t'));
+ let plain = !sentinel
+ && !edge_whitespace
+ && raw
+ .bytes()
+ .all(|byte| (0x20..=0x7e).contains(&byte) && byte != b'\t');
+ if plain {
+ Ok(Some(raw))
+ } else {
+ Ok(Some(format!(
+ "=?base64?{}?=",
+ base64::engine::general_purpose::STANDARD.encode(raw.as_bytes())
+ )))
+ }
+}
+
+fn nested_value<'a>(root: &'a Value, path: &[String]) -> Option<&'a Value> {
+ path.iter()
+ .try_fold(root, |value, key| value.as_object()?.get(key))
+}
+
+fn tool_param_headers(
+ tools: &[McpAppTool],
+ method: &str,
+ params: &Value,
+) -> Result, String> {
+ if method != "tools/call" {
+ return Ok(Vec::new());
+ }
+ let Some(tool_name) = params.get("name").and_then(Value::as_str) else {
+ return Ok(Vec::new());
+ };
+ let Some(tool) = tools.iter().find(|tool| tool.name == tool_name) else {
+ return Ok(Vec::new());
+ };
+ let arguments = params.get("arguments").unwrap_or(&Value::Null);
+ tool.param_headers
+ .iter()
+ .filter_map(|header| {
+ nested_value(arguments, &header.path).map(|value| {
+ mcp_param_header_value(value).map(|encoded| {
+ encoded.map(|value| (format!("Mcp-Param-{}", header.name), value))
+ })
+ })
+ })
+ .collect::, _>>()
+ .map(|values| values.into_iter().flatten().collect())
+}
+
+/// Build the per-request MCP headers for one era.
+///
+/// Legacy requests carry the negotiated `mcp-protocol-version` plus the
+/// server-issued `mcp-session-id`, exactly as before dual-era support. Modern
+/// requests instead carry `MCP-Protocol-Version`, `Mcp-Method`, and — for
+/// `tools/call`, `resources/read`, and `prompts/get` — `Mcp-Name`; a session
+/// header is never sent in the modern era.
+fn build_mcp_headers(
+ era: McpEra,
+ protocol_version: Option<&str>,
+ session_id: Option<&str>,
+ payload: &Value,
+ param_headers: &[(String, String)],
+) -> Vec<(String, String)> {
+ match era {
+ McpEra::Legacy => {
+ let mut headers = Vec::new();
+ if let Some(protocol_version) = protocol_version {
+ headers.push((
+ "mcp-protocol-version".to_string(),
+ protocol_version.to_string(),
+ ));
+ }
+ if let Some(session_id) = session_id {
+ headers.push(("mcp-session-id".to_string(), session_id.to_string()));
+ }
+ headers
+ }
+ McpEra::Modern => {
+ let mut headers = vec![(
+ "MCP-Protocol-Version".to_string(),
+ protocol_version
+ .unwrap_or(MCP_MODERN_PROTOCOL_VERSION)
+ .to_string(),
+ )];
+ if let Some(method) = payload.get("method").and_then(Value::as_str) {
+ headers.push(("Mcp-Method".to_string(), method.to_string()));
+ if let Some(name) = payload
+ .get("params")
+ .and_then(|params| modern_mcp_name(method, params))
+ {
+ headers.push(("Mcp-Name".to_string(), mcp_name_header_value(&name)));
+ }
+ }
+ headers.extend(param_headers.iter().cloned());
+ headers
+ }
+ }
+}
+
+/// True when a JSON-RPC error means resource-not-found. The modern revision
+/// moved this code from `-32002` to `-32602`; both are accepted.
+fn is_resource_not_found(error: &Value) -> bool {
+ matches!(
+ error.get("code").and_then(Value::as_i64),
+ Some(-32002 | -32602)
+ )
+}
+
+/// Completion state of a JSON-RPC result. The modern revision may attach
+/// `resultType`; an absent value MUST be read as `"complete"`.
+fn result_completion(result: &Value) -> &str {
+ result
+ .get("resultType")
+ .and_then(Value::as_str)
+ .unwrap_or("complete")
+}
+
+/// Extract the JSON-RPC `result`, tolerating the modern advisory fields
+/// (`resultType`, `ttlMs`, `cacheScope`) rather than failing on them.
+fn extract_result(response: &Value, method: &str) -> Result {
+ let result = response
+ .get("result")
+ .ok_or_else(|| format!("MCP {method} response is missing result"))?;
+ if result_completion(result).is_empty() {
+ return Err(format!("MCP {method} result declared an empty resultType"));
+ }
+ Ok(result.clone())
+}
+
+fn recognized_modern_error(body: Option<&Value>) -> Option<&Value> {
+ let error = body?.get("error")?;
+ let code = error.get("code").and_then(Value::as_i64)?;
+ MODERN_MCP_ERROR_CODES.contains(&code).then_some(error)
+}
+
+fn advertised_supported_versions(error: &Value) -> Vec {
+ error
+ .pointer("/data/supported")
+ .and_then(Value::as_array)
+ .map(|versions| {
+ versions
+ .iter()
+ .filter_map(Value::as_str)
+ .map(ToOwned::to_owned)
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+/// Classification of the modern-first era probe.
+#[derive(Debug, PartialEq, Eq)]
+enum ProbeOutcome {
+ /// The server answered the modern request.
+ Modern,
+ /// The server is modern but rejected our protocol version (`-32022`);
+ /// retry with a mutually supported version from this list.
+ ModernRetry { supported: Vec },
+ /// The server is modern and rejected the request for a non-version reason.
+ ModernError { message: String },
+ /// The server does not speak the modern revision; use the legacy handshake.
+ Legacy,
+}
+
+/// Classify a modern-probe response per the spec's Backward Compatibility
+/// rule: a recognized modern JSON-RPC error means the server speaks modern
+/// (retry or correct, never fall back); an empty or unrecognized `400` body
+/// and HTTP `404`/`405` mean the legacy handshake is required.
+fn classify_probe(status: reqwest::StatusCode, body: Option<&Value>) -> ProbeOutcome {
+ if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::METHOD_NOT_ALLOWED
+ {
+ return ProbeOutcome::Legacy;
+ }
+ if status.is_success() && body.is_some_and(|value| value.get("result").is_some()) {
+ return ProbeOutcome::Modern;
+ }
+ if let Some(error) = recognized_modern_error(body) {
+ if error.get("code").and_then(Value::as_i64) == Some(-32022) {
+ return ProbeOutcome::ModernRetry {
+ supported: advertised_supported_versions(error),
+ };
+ }
+ return ProbeOutcome::ModernError {
+ message: format!(
+ "MCP server rejected the modern request: {}",
+ format_rpc_error(error)
+ ),
+ };
+ }
+ ProbeOutcome::Legacy
+}
+
+/// POST one JSON-RPC payload with era-appropriate headers, preserving the
+/// HTTP status and leniently parsed body for era-probe inspection.
+async fn post_mcp_raw(
+ client: &Client,
+ endpoint: &Url,
+ era: McpEra,
+ protocol_version: Option<&str>,
+ session_id: Option<&str>,
+ payload: &Value,
+ param_headers: &[(String, String)],
+) -> Result {
+ let mut request = client
+ .post(endpoint.clone())
+ .header(reqwest::header::CONTENT_TYPE, "application/json")
+ .header(
+ reqwest::header::ACCEPT,
+ "application/json, text/event-stream",
+ )
+ .json(payload);
+ for (name, value) in
+ build_mcp_headers(era, protocol_version, session_id, payload, param_headers)
+ {
+ request = request.header(name.as_str(), value.as_str());
+ }
+ let response = request
+ .send()
+ .await
+ .map_err(|error| format!("MCP request failed: {error}"))?;
+ read_capped_reply(response, payload.get("id").and_then(Value::as_u64)).await
+}
+
+/// POST one JSON-RPC payload and require a successful, error-free response.
+async fn post_mcp(
+ client: &Client,
+ endpoint: &Url,
+ era: McpEra,
+ protocol_version: Option<&str>,
+ session_id: Option<&str>,
+ payload: &Value,
+) -> Result {
+ wire_response(
+ post_mcp_raw(
+ client,
+ endpoint,
+ era,
+ protocol_version,
+ session_id,
+ payload,
+ &[],
+ )
+ .await?,
+ )
+}
+
+async fn request(
+ connection: &McpServerConnection,
+ method: &str,
+ params: Value,
+) -> Result {
+ let id = connection.next_request_id.fetch_add(1, Ordering::Relaxed);
+ let param_headers = if connection.era == McpEra::Modern {
+ tool_param_headers(&connection.tools, method, ¶ms)?
+ } else {
+ Vec::new()
+ };
+ let params = prepare_params(connection.era, params, &connection.protocol_version);
+ let reply = post_mcp_raw(
+ &connection.client,
+ &connection.endpoint,
+ connection.era,
+ Some(&connection.protocol_version),
+ connection.session_id.as_deref(),
+ &json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "method": method,
+ "params": params,
+ }),
+ ¶m_headers,
+ )
+ .await?;
+ if !reply.status.is_success() {
+ return Err(format!("MCP server returned HTTP {}", reply.status));
+ }
+ if let Some(error) = reply.value.as_ref().and_then(|value| value.get("error")) {
+ if method == "resources/read" && is_resource_not_found(error) {
+ return Err(format!(
+ "MCP resource not found: {}",
+ format_rpc_error(error)
+ ));
+ }
+ return Err(format!("MCP request failed: {}", format_rpc_error(error)));
+ }
+ reply
+ .value
+ .ok_or_else(|| format!("MCP {method} returned no response"))
+}
+
+/// Result of the modern-first probe against a new origin.
+enum ModernProbe {
+ /// The origin speaks the modern revision; carries the probe's `tools/list`
+ /// JSON-RPC response and the negotiated protocol version.
+ Modern {
+ response: Value,
+ protocol_version: String,
+ },
+ /// The origin requires the legacy `initialize` handshake.
+ Legacy,
+}
+
+async fn modern_probe_once(
+ client: &Client,
+ endpoint: &Url,
+ protocol_version: &str,
+ id: u64,
+) -> Result {
+ let payload = json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "method": "tools/list",
+ "params": prepare_params(McpEra::Modern, json!({}), protocol_version),
+ });
+ post_mcp_raw(
+ client,
+ endpoint,
+ McpEra::Modern,
+ Some(protocol_version),
+ None,
+ &payload,
+ &[],
+ )
+ .await
+}
+
+/// Probe an origin with a modern `tools/list` request and decide its era. On
+/// `-32022` the probe retries once with a mutually supported version from the
+/// server's advertised `error.data.supported` list, and falls back to the
+/// legacy handshake when only the legacy version is mutually supported.
+async fn probe_modern(client: &Client, endpoint: &Url) -> Result {
+ let reply = modern_probe_once(client, endpoint, MCP_MODERN_PROTOCOL_VERSION, 1).await?;
+ match classify_probe(reply.status, reply.value.as_ref()) {
+ ProbeOutcome::Modern => Ok(ModernProbe::Modern {
+ response: reply
+ .value
+ .ok_or_else(|| "MCP modern probe returned no response".to_string())?,
+ protocol_version: MCP_MODERN_PROTOCOL_VERSION.to_string(),
+ }),
+ ProbeOutcome::Legacy => Ok(ModernProbe::Legacy),
+ ProbeOutcome::ModernError { message } => Err(message),
+ ProbeOutcome::ModernRetry { supported } => {
+ if supported
+ .iter()
+ .any(|version| version == MCP_MODERN_PROTOCOL_VERSION)
+ {
+ let retry =
+ modern_probe_once(client, endpoint, MCP_MODERN_PROTOCOL_VERSION, 2).await?;
+ match classify_probe(retry.status, retry.value.as_ref()) {
+ ProbeOutcome::Modern => Ok(ModernProbe::Modern {
+ response: retry
+ .value
+ .ok_or_else(|| "MCP modern probe returned no response".to_string())?,
+ protocol_version: MCP_MODERN_PROTOCOL_VERSION.to_string(),
+ }),
+ _ => Err("MCP server rejected the retried modern protocol version".to_string()),
+ }
+ } else if supported
+ .iter()
+ .any(|version| version == MCP_PROTOCOL_VERSION)
+ {
+ Ok(ModernProbe::Legacy)
+ } else if supported.is_empty() {
+ Err(
+ "MCP server rejected the protocol version without advertising alternatives"
+ .to_string(),
+ )
+ } else {
+ Err(format!(
+ "MCP server supports no mutual protocol version (offered: {})",
+ supported.join(", ")
+ ))
+ }
+ }
+ }
+}
+
+#[path = "mcp_apps_host.rs"]
+mod host;
+#[cfg(test)]
+use host::{
+ app_tool_allowed, build_tool_call_params, sandbox_proxy_html, sandbox_url_for_platform,
+};
+pub use host::{
+ call_mcp_app_tool, connect_mcp_app_server, disconnect_mcp_app_server, handle_mcp_app_protocol,
+ inspect_mcp_app_resource, list_mcp_app_resources, list_mcp_app_tools, prepare_mcp_app_view,
+ read_mcp_app_resource, release_mcp_app_view,
+};
+
+#[cfg(test)]
+#[path = "mcp_apps_tests.rs"]
+mod tests;
+
+#[cfg(test)]
+#[path = "mcp_apps_live_tests.rs"]
+mod live_tests;
diff --git a/desktop/src-tauri/src/commands/mcp_apps_host.rs b/desktop/src-tauri/src/commands/mcp_apps_host.rs
new file mode 100644
index 0000000000..ede46563dd
--- /dev/null
+++ b/desktop/src-tauri/src/commands/mcp_apps_host.rs
@@ -0,0 +1,483 @@
+use super::*;
+use std::borrow::Cow;
+
+pub(super) fn app_tool_allowed(tool: &McpAppTool, caller: McpAppToolCaller) -> bool {
+ match caller {
+ McpAppToolCaller::Host => tool.visibility.iter().any(|value| value == "model"),
+ McpAppToolCaller::App => tool.visibility.iter().any(|value| value == "app"),
+ }
+}
+
+const BUZZ_CONTEXT_META_KEY: &str = "xyz.block.buzz/context";
+const BUZZ_META_PREFIX: &str = "xyz.block.buzz/";
+
+/// Build a `tools/call` params object with host-owned Buzz binding context.
+///
+/// MCP callers may provide generic `_meta` (for example a progress token), so
+/// unrelated entries are preserved. The host removes its complete metadata
+/// namespace before it writes current host-owned values. This prevents an App
+/// from pre-populating current or future Buzz metadata. The values are context
+/// only; authorization remains in the host and relay layers.
+pub(super) fn build_tool_call_params(
+ name: &str,
+ arguments: Value,
+ caller_meta: Option,
+ context: Option<&McpAppInvocationContext>,
+) -> Value {
+ let mut params = serde_json::Map::from_iter([
+ ("name".to_string(), Value::String(name.to_string())),
+ ("arguments".to_string(), arguments),
+ ]);
+ let mut meta = match caller_meta {
+ Some(Value::Object(value)) => value,
+ _ => serde_json::Map::new(),
+ };
+
+ meta.retain(|key, _| !key.starts_with(BUZZ_META_PREFIX));
+ if let Some(context) = context {
+ let mut buzz_context = serde_json::Map::new();
+ insert_context_refs(&mut buzz_context, context);
+ if !buzz_context.is_empty() {
+ meta.insert(
+ BUZZ_CONTEXT_META_KEY.to_string(),
+ Value::Object(buzz_context),
+ );
+ }
+ }
+ if !meta.is_empty() {
+ params.insert("_meta".to_string(), Value::Object(meta));
+ }
+ Value::Object(params)
+}
+
+fn insert_context_refs(
+ target: &mut serde_json::Map,
+ context: &McpAppInvocationContext,
+) {
+ for (key, value) in [
+ ("communityRef", context.community_ref.as_deref()),
+ ("channelRef", context.channel_ref.as_deref()),
+ ("installationRef", context.installation_ref.as_deref()),
+ ] {
+ if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
+ target.insert(key.to_string(), Value::String(value.to_string()));
+ }
+ }
+}
+
+/// Connect to a reviewed Streamable HTTP MCP server and discover its Apps.
+///
+/// Dual-era: the connection probes the modern (`2026-07-28`) revision first
+/// and falls back to the legacy (`2025-11-25`) `initialize` handshake only
+/// when the probe classifies the origin as legacy.
+#[tauri::command]
+pub async fn connect_mcp_app_server(
+ endpoint: String,
+ state: State<'_, McpAppHostState>,
+) -> Result {
+ ensure_mcp_apps_supported()?;
+ let endpoint = validate_mcp_endpoint(&endpoint)?;
+ let client = build_pinned_client(&endpoint).await?;
+ let (connection, server_name, server_version, tools_response) =
+ match probe_modern(&client, &endpoint).await? {
+ ModernProbe::Modern {
+ response,
+ protocol_version,
+ } => {
+ let connection = McpServerConnection {
+ endpoint: endpoint.clone(),
+ client,
+ era: McpEra::Modern,
+ protocol_version,
+ session_id: None,
+ next_request_id: Arc::new(AtomicU64::new(3)),
+ tools: Vec::new(),
+ resources: Vec::new(),
+ };
+ (connection, endpoint.to_string(), None, response)
+ }
+ ModernProbe::Legacy => {
+ let initialize = post_mcp(
+ &client,
+ &endpoint,
+ McpEra::Legacy,
+ None,
+ None,
+ &json!({
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "protocolVersion": MCP_PROTOCOL_VERSION,
+ "capabilities": {
+ "extensions": {
+ "io.modelcontextprotocol/ui": {
+ "mimeTypes": [MCP_APP_MIME_TYPE]
+ }
+ }
+ },
+ "clientInfo": {
+ "name": "Buzz Desktop",
+ "version": env!("CARGO_PKG_VERSION")
+ }
+ }
+ }),
+ )
+ .await?;
+ let value = initialize
+ .value
+ .ok_or_else(|| "MCP initialize returned no response".to_string())?;
+ let protocol_version =
+ text(value.pointer("/result/protocolVersion")).ok_or_else(|| {
+ "MCP initialize response is missing protocolVersion".to_string()
+ })?;
+ let server_name = text(value.pointer("/result/serverInfo/name"))
+ .unwrap_or_else(|| endpoint.to_string());
+ let server_version = text(value.pointer("/result/serverInfo/version"));
+ let connection = McpServerConnection {
+ endpoint: endpoint.clone(),
+ client,
+ era: McpEra::Legacy,
+ protocol_version,
+ session_id: initialize.session_id,
+ next_request_id: Arc::new(AtomicU64::new(2)),
+ tools: Vec::new(),
+ resources: Vec::new(),
+ };
+ let _ = post_mcp(
+ &connection.client,
+ &connection.endpoint,
+ McpEra::Legacy,
+ Some(&connection.protocol_version),
+ connection.session_id.as_deref(),
+ &json!({
+ "jsonrpc": "2.0",
+ "method": "notifications/initialized",
+ "params": {}
+ }),
+ )
+ .await?;
+ let tools_response = request(&connection, "tools/list", json!({})).await?;
+ (connection, server_name, server_version, tools_response)
+ }
+ };
+ let tools = parse_tools(&tools_response)?;
+ let resources = request(&connection, "resources/list", json!({}))
+ .await
+ .and_then(|value| parse_resources(&value))
+ .unwrap_or_default();
+ let protocol_version = connection.protocol_version.clone();
+ let server_id = Uuid::new_v4().to_string();
+ let connection = McpServerConnection {
+ tools: tools.clone(),
+ resources: resources.clone(),
+ ..connection
+ };
+ let mut servers = state.servers.lock().await;
+ if servers.len() >= MAX_SERVERS {
+ return Err("Too many MCP App servers are connected".to_string());
+ }
+ servers.insert(server_id.clone(), connection);
+ Ok(McpAppServerDescriptor {
+ server_id,
+ endpoint: endpoint.to_string(),
+ name: server_name,
+ version: server_version,
+ protocol_version,
+ tools,
+ resources,
+ })
+}
+
+/// List the reviewed tools for one connected MCP server.
+#[tauri::command]
+pub async fn list_mcp_app_tools(
+ server_id: String,
+ state: State<'_, McpAppHostState>,
+) -> Result, String> {
+ state
+ .servers
+ .lock()
+ .await
+ .get(&server_id)
+ .map(|connection| connection.tools.clone())
+ .ok_or_else(|| "MCP App server is not connected".to_string())
+}
+
+/// List the reviewed resources for one connected MCP server.
+#[tauri::command]
+pub async fn list_mcp_app_resources(
+ server_id: String,
+ state: State<'_, McpAppHostState>,
+) -> Result, String> {
+ state
+ .servers
+ .lock()
+ .await
+ .get(&server_id)
+ .map(|connection| connection.resources.clone())
+ .ok_or_else(|| "MCP App server is not connected".to_string())
+}
+
+/// Execute a reviewed MCP tool for the host or the isolated App.
+#[tauri::command]
+pub async fn call_mcp_app_tool(
+ server_id: String,
+ name: String,
+ arguments: Value,
+ caller: McpAppToolCaller,
+ caller_meta: Option,
+ context: Option,
+ state: State<'_, McpAppHostState>,
+) -> Result {
+ let connection = state
+ .servers
+ .lock()
+ .await
+ .get(&server_id)
+ .cloned()
+ .ok_or_else(|| "MCP App server is not connected".to_string())?;
+ let tool = connection
+ .tools
+ .iter()
+ .find(|tool| tool.name == name)
+ .ok_or_else(|| "MCP App requested an unknown tool".to_string())?;
+ if !app_tool_allowed(tool, caller) {
+ return Err("MCP App tool is not visible to this caller".to_string());
+ }
+ request(
+ &connection,
+ "tools/call",
+ build_tool_call_params(name.as_str(), arguments, caller_meta, context.as_ref()),
+ )
+ .await
+ .and_then(|value| extract_result(&value, "tools/call"))
+}
+
+/// Read a resource for an initialized AppBridge request.
+#[tauri::command]
+pub async fn read_mcp_app_resource(
+ server_id: String,
+ uri: String,
+ state: State<'_, McpAppHostState>,
+) -> Result {
+ let connection = state
+ .servers
+ .lock()
+ .await
+ .get(&server_id)
+ .cloned()
+ .ok_or_else(|| "MCP App server is not connected".to_string())?;
+ if !connection
+ .resources
+ .iter()
+ .any(|resource| resource.uri == uri)
+ {
+ return Err("MCP App requested an undiscovered resource".to_string());
+ }
+ request(&connection, "resources/read", json!({"uri": uri}))
+ .await
+ .and_then(|value| extract_result(&value, "resources/read"))
+}
+
+async fn resource_policy(
+ connection: &McpServerConnection,
+ uri: &str,
+) -> Result<(String, McpAppResourcePolicy), String> {
+ let response = request(connection, "resources/read", json!({"uri": uri})).await?;
+ let listing = connection
+ .resources
+ .iter()
+ .find(|resource| resource.uri == uri);
+ let (html, csp, requested_permissions) = parse_ui_resource(&response, uri, listing)?;
+ Ok((
+ html,
+ McpAppResourcePolicy {
+ csp,
+ requested_permissions,
+ },
+ ))
+}
+
+/// Read and sanitize the authoritative resource policy for user review.
+#[tauri::command]
+pub async fn inspect_mcp_app_resource(
+ server_id: String,
+ uri: String,
+ state: State<'_, McpAppHostState>,
+) -> Result {
+ let connection = state
+ .servers
+ .lock()
+ .await
+ .get(&server_id)
+ .cloned()
+ .ok_or_else(|| "MCP App server is not connected".to_string())?;
+ if !connection
+ .tools
+ .iter()
+ .any(|tool| tool.ui_resource_uri.as_deref() == Some(uri.as_str()))
+ {
+ return Err("MCP App resource is not declared by a reviewed tool".to_string());
+ }
+ resource_policy(&connection, &uri)
+ .await
+ .map(|(_, policy)| policy)
+}
+
+pub(super) fn sandbox_url_for_platform(view_id: &str, use_windows_workaround: bool) -> String {
+ if use_windows_workaround {
+ format!("http://buzz-mcp-app.localhost/{view_id}")
+ } else {
+ format!("buzz-mcp-app://localhost/{view_id}")
+ }
+}
+
+fn sandbox_url(view_id: &str) -> String {
+ sandbox_url_for_platform(view_id, cfg!(target_os = "windows"))
+}
+
+/// Read and validate one UI resource, then register its CSP-bound sandbox URL.
+#[tauri::command]
+pub async fn prepare_mcp_app_view(
+ server_id: String,
+ uri: String,
+ approved_policy: McpAppResourcePolicy,
+ state: State<'_, McpAppHostState>,
+) -> Result {
+ let connection = state
+ .servers
+ .lock()
+ .await
+ .get(&server_id)
+ .cloned()
+ .ok_or_else(|| "MCP App server is not connected".to_string())?;
+ if !connection
+ .tools
+ .iter()
+ .any(|tool| tool.ui_resource_uri.as_deref() == Some(uri.as_str()))
+ {
+ return Err("MCP App resource is not declared by a reviewed tool".to_string());
+ }
+ let (html, policy) = resource_policy(&connection, &uri).await?;
+ if !policy_is_subset(&policy, &approved_policy) {
+ return Err(
+ "The MCP App requests capabilities that were not approved. Remove and add the channel app again to review the change."
+ .to_string(),
+ );
+ }
+ let view_id = Uuid::new_v4().to_string();
+ let mut views = state
+ .views
+ .lock()
+ .map_err(|_| "MCP App view registry is unavailable".to_string())?;
+ if views.len() >= MAX_VIEWS {
+ return Err("Too many MCP App views are open".to_string());
+ }
+ views.insert(
+ view_id.clone(),
+ ViewPolicy {
+ server_id,
+ csp: sandbox_csp(&policy.csp),
+ },
+ );
+ Ok(PreparedMcpAppView {
+ sandbox_url: sandbox_url(&view_id),
+ view_id,
+ html,
+ csp: policy.csp,
+ requested_permissions: policy.requested_permissions,
+ })
+}
+
+/// Release an isolated MCP App view and its CSP policy.
+#[tauri::command]
+pub fn release_mcp_app_view(
+ view_id: String,
+ state: State<'_, McpAppHostState>,
+) -> Result<(), String> {
+ state
+ .views
+ .lock()
+ .map_err(|_| "MCP App view registry is unavailable".to_string())?
+ .remove(&view_id);
+ Ok(())
+}
+
+/// Close an MCP server connection and release all views created from it.
+#[tauri::command]
+pub async fn disconnect_mcp_app_server(
+ server_id: String,
+ state: State<'_, McpAppHostState>,
+) -> Result<(), String> {
+ let connection = state.servers.lock().await.remove(&server_id);
+ if let Some((connection, session_id)) =
+ connection.and_then(|connection| connection.session_id.clone().map(|id| (connection, id)))
+ {
+ let _ = connection
+ .client
+ .delete(connection.endpoint)
+ .header("mcp-protocol-version", connection.protocol_version)
+ .header("mcp-session-id", session_id)
+ .send()
+ .await;
+ }
+ state
+ .views
+ .lock()
+ .map_err(|_| "MCP App view registry is unavailable".to_string())?
+ .retain(|_, view| view.server_id != server_id);
+ Ok(())
+}
+
+fn html_response(status: u16, body: &str, csp: Option<&str>) -> http::Response> {
+ let mut builder = http::Response::builder()
+ .status(status)
+ .header("content-type", "text/html; charset=utf-8")
+ .header("cache-control", "no-store")
+ .header("x-content-type-options", "nosniff")
+ .header(
+ "permissions-policy",
+ "camera=(), microphone=(), geolocation=(), clipboard-write=()",
+ );
+ if let Some(csp) = csp {
+ builder = builder.header("content-security-policy", csp);
+ }
+ builder
+ .body(body.as_bytes().to_vec())
+ .unwrap_or_else(|_| http::Response::new(Vec::new()))
+}
+
+pub(super) fn sandbox_proxy_html() -> Cow<'static, str> {
+ #[cfg(debug_assertions)]
+ {
+ Cow::Owned(SANDBOX_PROXY_HTML.replace(
+ " /* BUZZ_MCP_APP_DEV_ORIGINS */",
+ ",\n \"http://localhost:1420\",\n \"http://127.0.0.1:1420\"",
+ ))
+ }
+ #[cfg(not(debug_assertions))]
+ {
+ Cow::Borrowed(SANDBOX_PROXY_HTML)
+ }
+}
+
+/// Serve the trusted outer sandbox proxy from a Tauri-owned isolated origin.
+pub fn handle_mcp_app_protocol(
+ app: &AppHandle,
+ request: &http::Request>,
+) -> http::Response> {
+ let view_id = request.uri().path().trim_matches('/');
+ if Uuid::parse_str(view_id).is_err() {
+ return html_response(404, "not found", None);
+ }
+ let state = app.state::();
+ let views = match state.views.lock() {
+ Ok(views) => views,
+ Err(_) => return html_response(503, "unavailable", None),
+ };
+ let Some(view) = views.get(view_id) else {
+ return html_response(404, "not found", None);
+ };
+ html_response(200, &sandbox_proxy_html(), Some(&view.csp))
+}
diff --git a/desktop/src-tauri/src/commands/mcp_apps_live_tests.rs b/desktop/src-tauri/src/commands/mcp_apps_live_tests.rs
new file mode 100644
index 0000000000..2284dce57f
--- /dev/null
+++ b/desktop/src-tauri/src/commands/mcp_apps_live_tests.rs
@@ -0,0 +1,118 @@
+use super::*;
+use axum::{extract::State as AxumState, routing::post, Json, Router};
+use tokio::net::TcpListener;
+
+type Calls = Arc>>;
+
+async fn handle_mcp(AxumState(calls): AxumState, Json(payload): Json) -> Json {
+ calls.lock().await.push(payload.clone());
+ let id = payload.get("id").cloned().unwrap_or(Value::Null);
+ let result = match payload.get("method").and_then(Value::as_str) {
+ Some("tools/list") => json!({
+ "tools": [{
+ "name": "prepare_brief",
+ "title": "Signal reader",
+ "inputSchema": {
+ "type": "object",
+ "properties": {"storyId": {"type": "string"}}
+ },
+ "_meta": {
+ "ui": {
+ "resourceUri": "ui://review/signal-reader",
+ "visibility": ["app", "model"]
+ }
+ }
+ }]
+ }),
+ Some("tools/call") => json!({
+ "content": [{
+ "type": "text",
+ "text": "Prepared through the live Streamable HTTP path."
+ }]
+ }),
+ method => panic!("unexpected MCP method: {method:?}"),
+ };
+ Json(json!({"jsonrpc": "2.0", "id": id, "result": result}))
+}
+
+#[tokio::test]
+async fn live_streamable_http_round_trip_preserves_host_context() {
+ let calls: Calls = Arc::new(AsyncMutex::new(Vec::new()));
+ let app = Router::new()
+ .route("/mcp", post(handle_mcp))
+ .with_state(calls.clone());
+ let listener = TcpListener::bind(("127.0.0.1", 0))
+ .await
+ .expect("bind MCP test server");
+ let address = listener.local_addr().expect("read MCP test address");
+ let server = tokio::spawn(async move {
+ axum::serve(listener, app)
+ .await
+ .expect("serve MCP test endpoint");
+ });
+
+ let endpoint = validate_mcp_endpoint(&format!("http://{address}/mcp"))
+ .expect("loopback MCP endpoint must be allowed");
+ let client = build_pinned_client(&endpoint)
+ .await
+ .expect("build pinned MCP client");
+ let (tools_response, protocol_version) = match probe_modern(&client, &endpoint)
+ .await
+ .expect("probe modern MCP server")
+ {
+ ModernProbe::Modern {
+ response,
+ protocol_version,
+ } => (response, protocol_version),
+ ModernProbe::Legacy => panic!("test server must negotiate the modern MCP revision"),
+ };
+ let tools = parse_tools(&tools_response).expect("parse advertised MCP App tool");
+ let connection = McpServerConnection {
+ endpoint,
+ client,
+ era: McpEra::Modern,
+ protocol_version,
+ session_id: None,
+ next_request_id: Arc::new(AtomicU64::new(2)),
+ tools,
+ resources: Vec::new(),
+ };
+ let result = request(
+ &connection,
+ "tools/call",
+ build_tool_call_params(
+ "prepare_brief",
+ json!({"storyId": "interactive-tools"}),
+ None,
+ Some(&McpAppInvocationContext {
+ community_ref: Some("community-1".to_string()),
+ channel_ref: Some("channel-1".to_string()),
+ installation_ref: Some("installation-1".to_string()),
+ }),
+ ),
+ )
+ .await
+ .expect("call live MCP App tool");
+
+ assert_eq!(
+ result.pointer("/result/content/0/text"),
+ Some(&json!("Prepared through the live Streamable HTTP path."))
+ );
+ let observed = calls.lock().await;
+ assert_eq!(observed.len(), 2);
+ assert_eq!(
+ observed[1].pointer("/params/_meta/xyz.block.buzz~1context"),
+ Some(&json!({
+ "communityRef": "community-1",
+ "channelRef": "channel-1",
+ "installationRef": "installation-1"
+ }))
+ );
+ assert_eq!(
+ observed[1].pointer("/params/_meta/io.modelcontextprotocol~1protocolVersion"),
+ Some(&json!(MCP_MODERN_PROTOCOL_VERSION))
+ );
+
+ drop(observed);
+ server.abort();
+}
diff --git a/desktop/src-tauri/src/commands/mcp_apps_model.rs b/desktop/src-tauri/src/commands/mcp_apps_model.rs
new file mode 100644
index 0000000000..bceb2d6716
--- /dev/null
+++ b/desktop/src-tauri/src/commands/mcp_apps_model.rs
@@ -0,0 +1,345 @@
+use super::*;
+use std::collections::HashSet;
+
+/// Which MCP revision an origin speaks. Detected once by the modern-first
+/// probe in [`connect_mcp_app_server`] and cached on the connection: the era
+/// is a property of the origin, not of individual requests.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum McpEra {
+ /// Revision `2026-07-28`: no handshake, no sessions, per-request headers.
+ Modern,
+ /// Revision `2025-11-25`: `initialize` handshake and `mcp-session-id`.
+ Legacy,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) struct McpParamHeader {
+ pub(super) path: Vec,
+ pub(super) name: String,
+}
+
+#[derive(Debug, Clone)]
+pub(super) struct McpServerConnection {
+ pub(super) endpoint: Url,
+ pub(super) client: Client,
+ pub(super) era: McpEra,
+ pub(super) protocol_version: String,
+ pub(super) session_id: Option,
+ pub(super) next_request_id: Arc,
+ pub(super) tools: Vec,
+ pub(super) resources: Vec,
+}
+
+#[derive(Debug, Clone)]
+pub(super) struct ViewPolicy {
+ pub(super) server_id: String,
+ pub(super) csp: String,
+}
+
+/// Runtime state for reviewed MCP servers and isolated app views.
+pub struct McpAppHostState {
+ pub(super) servers: AsyncMutex>,
+ pub(super) views: Mutex>,
+}
+
+impl Default for McpAppHostState {
+ fn default() -> Self {
+ Self {
+ servers: AsyncMutex::new(HashMap::new()),
+ views: Mutex::new(HashMap::new()),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct McpAppTool {
+ pub(super) name: String,
+ pub(super) title: Option,
+ pub(super) description: Option,
+ pub(super) input_schema: Value,
+ pub(super) output_schema: Option,
+ pub(super) annotations: Option,
+ pub(super) meta: Value,
+ pub(super) ui_resource_uri: Option,
+ pub(super) visibility: Vec,
+ #[serde(skip)]
+ pub(super) param_headers: Vec,
+}
+
+/// Host-authored references for one channel app tool call.
+///
+/// These values identify the local Buzz binding only. They are context, not
+/// authorization, and are written after any caller-supplied metadata merge.
+#[derive(Debug, Clone, Default, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct McpAppInvocationContext {
+ pub(super) community_ref: Option,
+ pub(super) channel_ref: Option,
+ pub(super) installation_ref: Option,
+}
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct McpAppResource {
+ pub(super) uri: String,
+ pub(super) name: Option,
+ pub(super) title: Option,
+ pub(super) description: Option,
+ pub(super) mime_type: Option,
+ pub(super) meta: Value,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct McpAppServerDescriptor {
+ pub(super) server_id: String,
+ pub(super) endpoint: String,
+ pub(super) name: String,
+ pub(super) version: Option,
+ pub(super) protocol_version: String,
+ pub(super) tools: Vec,
+ pub(super) resources: Vec,
+}
+
+#[derive(Debug, Clone, Default, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct McpAppResourceCsp {
+ #[serde(default)]
+ pub(super) connect_domains: Vec,
+ #[serde(default)]
+ pub(super) resource_domains: Vec,
+ #[serde(default)]
+ pub(super) frame_domains: Vec,
+ #[serde(default)]
+ pub(super) base_uri_domains: Vec,
+}
+
+#[derive(Debug, Clone, Default, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct McpAppResourcePermissions {
+ pub(super) camera: Option,
+ pub(super) microphone: Option,
+ pub(super) geolocation: Option,
+ pub(super) clipboard_write: Option,
+}
+
+#[derive(Debug, Clone, Default, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct McpAppResourcePolicy {
+ pub(super) csp: McpAppResourceCsp,
+ pub(super) requested_permissions: McpAppResourcePermissions,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PreparedMcpAppView {
+ pub(super) view_id: String,
+ pub(super) sandbox_url: String,
+ pub(super) html: String,
+ pub(super) csp: McpAppResourceCsp,
+ /// Permissions are reported for review but not granted by this host layer.
+ pub(super) requested_permissions: McpAppResourcePermissions,
+}
+
+#[derive(Debug, Clone, Copy, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum McpAppToolCaller {
+ Host,
+ App,
+}
+
+#[derive(Debug)]
+pub(super) struct McpWireResponse {
+ pub(super) value: Option,
+ pub(super) session_id: Option,
+}
+
+/// Raw HTTP-level MCP reply that preserves the status code and a leniently
+/// parsed body, so the era probe can inspect non-2xx responses.
+#[derive(Debug)]
+pub(super) struct McpHttpReply {
+ pub(super) status: reqwest::StatusCode,
+ pub(super) value: Option,
+ pub(super) session_id: Option,
+}
+
+pub(super) fn text(value: Option<&Value>) -> Option {
+ value?
+ .as_str()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(ToOwned::to_owned)
+}
+
+pub(super) fn ui_resource_uri(meta: &Value) -> Option {
+ let nested = meta
+ .get("ui")
+ .and_then(|ui| ui.get("resourceUri"))
+ .and_then(Value::as_str);
+ let legacy = meta.get("ui/resourceUri").and_then(Value::as_str);
+ nested
+ .or(legacy)
+ .filter(|uri| uri.starts_with("ui://"))
+ .map(ToOwned::to_owned)
+}
+
+pub(super) fn tool_visibility(meta: &Value) -> Vec {
+ let visibility = meta
+ .get("ui")
+ .and_then(|ui| ui.get("visibility"))
+ .and_then(Value::as_array)
+ .map(|values| {
+ values
+ .iter()
+ .filter_map(Value::as_str)
+ .filter(|value| matches!(*value, "model" | "app"))
+ .map(ToOwned::to_owned)
+ .collect::>()
+ })
+ .unwrap_or_default();
+ if visibility.is_empty() {
+ vec!["model".to_string(), "app".to_string()]
+ } else {
+ visibility
+ }
+}
+
+pub(super) fn parse_tools(value: &Value) -> Result, String> {
+ let tools = value
+ .pointer("/result/tools")
+ .and_then(Value::as_array)
+ .ok_or_else(|| "MCP tools/list response is missing result.tools".to_string())?;
+ let mut parsed = Vec::new();
+ for tool in tools.iter().take(MAX_TOOLS) {
+ let name =
+ text(tool.get("name")).ok_or_else(|| "MCP tool is missing a valid name".to_string())?;
+ let input_schema = tool
+ .get("inputSchema")
+ .cloned()
+ .unwrap_or_else(|| json!({"type": "object", "properties": {}}));
+ let param_headers = match parse_param_headers(&input_schema) {
+ Ok(headers) => headers,
+ Err(reason) => {
+ tracing::warn!(
+ tool = %name,
+ reason = %reason,
+ "excluding MCP tool with invalid x-mcp-header annotation"
+ );
+ continue;
+ }
+ };
+ let meta = tool.get("_meta").cloned().unwrap_or_else(|| json!({}));
+ parsed.push(McpAppTool {
+ name,
+ title: text(tool.get("title")),
+ description: text(tool.get("description")),
+ input_schema,
+ output_schema: tool.get("outputSchema").cloned(),
+ annotations: tool.get("annotations").cloned(),
+ ui_resource_uri: ui_resource_uri(&meta),
+ visibility: tool_visibility(&meta),
+ meta,
+ param_headers,
+ });
+ }
+ Ok(parsed)
+}
+
+fn valid_header_token(value: &str) -> bool {
+ !value.is_empty()
+ && value.bytes().all(|byte| {
+ byte.is_ascii_alphanumeric()
+ || matches!(
+ byte,
+ b'!' | b'#'
+ | b'$'
+ | b'%'
+ | b'&'
+ | b'\''
+ | b'*'
+ | b'+'
+ | b'-'
+ | b'.'
+ | b'^'
+ | b'_'
+ | b'`'
+ | b'|'
+ | b'~'
+ )
+ })
+}
+
+pub(super) fn parse_param_headers(input_schema: &Value) -> Result, String> {
+ fn visit(
+ schema: &Value,
+ path: &mut Vec,
+ seen: &mut HashSet,
+ headers: &mut Vec,
+ ) -> Result<(), String> {
+ if let Some(annotation) = schema.get("x-mcp-header") {
+ let name = annotation
+ .as_str()
+ .ok_or_else(|| "x-mcp-header must be a string".to_string())?;
+ if !valid_header_token(name) {
+ return Err(format!("x-mcp-header {name:?} is not a valid HTTP token"));
+ }
+ if !seen.insert(name.to_ascii_lowercase()) {
+ return Err(format!(
+ "x-mcp-header {name:?} is not case-insensitively unique"
+ ));
+ }
+ if !matches!(
+ schema.get("type").and_then(Value::as_str),
+ Some("string" | "integer" | "boolean")
+ ) {
+ return Err(format!(
+ "x-mcp-header {name:?} must annotate a string, integer, or boolean"
+ ));
+ }
+ headers.push(McpParamHeader {
+ path: path.clone(),
+ name: name.to_string(),
+ });
+ }
+ if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
+ for (property, child) in properties {
+ path.push(property.clone());
+ visit(child, path, seen, headers)?;
+ path.pop();
+ }
+ }
+ Ok(())
+ }
+
+ let mut headers = Vec::new();
+ visit(
+ input_schema,
+ &mut Vec::new(),
+ &mut HashSet::new(),
+ &mut headers,
+ )?;
+ Ok(headers)
+}
+
+pub(super) fn parse_resources(value: &Value) -> Result, String> {
+ value
+ .pointer("/result/resources")
+ .and_then(Value::as_array)
+ .ok_or_else(|| "MCP resources/list response is missing result.resources".to_string())?
+ .iter()
+ .take(MAX_RESOURCES)
+ .map(|resource| {
+ let uri = text(resource.get("uri"))
+ .ok_or_else(|| "MCP resource is missing a valid URI".to_string())?;
+ Ok(McpAppResource {
+ uri,
+ name: text(resource.get("name")),
+ title: text(resource.get("title")),
+ description: text(resource.get("description")),
+ mime_type: text(resource.get("mimeType")),
+ meta: resource.get("_meta").cloned().unwrap_or_else(|| json!({})),
+ })
+ })
+ .collect()
+}
diff --git a/desktop/src-tauri/src/commands/mcp_apps_policy.rs b/desktop/src-tauri/src/commands/mcp_apps_policy.rs
new file mode 100644
index 0000000000..fee30a9cbe
--- /dev/null
+++ b/desktop/src-tauri/src/commands/mcp_apps_policy.rs
@@ -0,0 +1,192 @@
+use super::*;
+use std::collections::HashSet;
+use url::Host;
+
+fn resource_meta(content: &Value, listing: Option<&McpAppResource>) -> Value {
+ content
+ .get("_meta")
+ .or_else(|| content.get("meta"))
+ .cloned()
+ .or_else(|| listing.map(|resource| resource.meta.clone()))
+ .unwrap_or_else(|| json!({}))
+}
+
+pub(super) fn parse_ui_resource(
+ response: &Value,
+ requested_uri: &str,
+ listing: Option<&McpAppResource>,
+) -> Result<(String, McpAppResourceCsp, McpAppResourcePermissions), String> {
+ let contents = response
+ .pointer("/result/contents")
+ .and_then(Value::as_array)
+ .ok_or_else(|| "MCP resources/read response is missing result.contents".to_string())?;
+ if contents.len() != 1 {
+ return Err("MCP App resource must contain exactly one document".to_string());
+ }
+ let content = &contents[0];
+ if text(content.get("uri")).as_deref() != Some(requested_uri) {
+ return Err("MCP App resource URI does not match the request".to_string());
+ }
+ if text(content.get("mimeType")).as_deref() != Some(MCP_APP_MIME_TYPE) {
+ return Err(format!("MCP App resource must use {MCP_APP_MIME_TYPE}"));
+ }
+ let html = if let Some(text) = content.get("text").and_then(Value::as_str) {
+ text.to_string()
+ } else if let Some(blob) = content.get("blob").and_then(Value::as_str) {
+ let bytes = base64::engine::general_purpose::STANDARD
+ .decode(blob)
+ .map_err(|_| "MCP App resource blob is not valid base64".to_string())?;
+ String::from_utf8(bytes)
+ .map_err(|_| "MCP App resource blob is not valid UTF-8".to_string())?
+ } else {
+ return Err("MCP App resource has no text or blob content".to_string());
+ };
+ if html.len() > MAX_MCP_APP_HTML_BYTES {
+ return Err("MCP App HTML exceeds the 4 MiB limit".to_string());
+ }
+ let meta = resource_meta(content, listing);
+ let ui = meta.get("ui").cloned().unwrap_or_else(|| json!({}));
+ let csp = sanitize_csp(
+ serde_json::from_value(ui.get("csp").cloned().unwrap_or_else(|| json!({})))
+ .map_err(|error| format!("MCP App CSP metadata is invalid: {error}"))?,
+ );
+ let permissions =
+ serde_json::from_value(ui.get("permissions").cloned().unwrap_or_else(|| json!({})))
+ .map_err(|error| format!("MCP App permission metadata is invalid: {error}"))?;
+ Ok((html, csp, permissions))
+}
+
+pub(super) fn csp_origin(raw: &str) -> Option {
+ let raw = raw.trim();
+ let wildcard_suffix = raw
+ .strip_prefix("https://*.")
+ .or_else(|| raw.strip_prefix("wss://*."));
+ if let Some(suffix) = wildcard_suffix {
+ return valid_domain_name(suffix).then(|| raw.to_string());
+ }
+ let url = Url::parse(raw).ok()?;
+ if !url.username().is_empty()
+ || url.password().is_some()
+ || url.query().is_some()
+ || url.fragment().is_some()
+ || url.path() != "/"
+ {
+ return None;
+ }
+ let host = url.host()?;
+ if matches!(host, Host::Domain(domain) if !valid_domain_name(domain)) {
+ return None;
+ }
+ let loopback = match host {
+ Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"),
+ Host::Ipv4(address) => address.is_loopback(),
+ Host::Ipv6(address) => address.is_loopback(),
+ };
+ let private_ip_literal = match host {
+ Host::Domain(_) => false,
+ Host::Ipv4(address) => is_private_ip(address.into()),
+ Host::Ipv6(address) => is_private_ip(address.into()),
+ };
+ if private_ip_literal && !loopback {
+ return None;
+ }
+ if !(matches!(url.scheme(), "https" | "wss")
+ || matches!(url.scheme(), "http" | "ws") && loopback)
+ {
+ return None;
+ }
+ Some(url.origin().ascii_serialization())
+}
+
+fn valid_domain_name(domain: &str) -> bool {
+ !domain.is_empty()
+ && domain.len() <= 253
+ && domain.split('.').all(|label| {
+ !label.is_empty()
+ && label.len() <= 63
+ && label
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
+ && label
+ .as_bytes()
+ .first()
+ .is_some_and(u8::is_ascii_alphanumeric)
+ && label
+ .as_bytes()
+ .last()
+ .is_some_and(u8::is_ascii_alphanumeric)
+ })
+}
+
+fn sanitize_csp(csp: McpAppResourceCsp) -> McpAppResourceCsp {
+ fn sanitize(values: Vec) -> Vec {
+ values
+ .into_iter()
+ .filter_map(|value| csp_origin(&value))
+ .collect()
+ }
+ McpAppResourceCsp {
+ connect_domains: sanitize(csp.connect_domains),
+ resource_domains: sanitize(csp.resource_domains),
+ frame_domains: sanitize(csp.frame_domains),
+ base_uri_domains: sanitize(csp.base_uri_domains),
+ }
+}
+
+fn csp_is_subset(requested: &McpAppResourceCsp, approved: &McpAppResourceCsp) -> bool {
+ fn values_are_subset(requested: &[String], approved: &[String]) -> bool {
+ let approved = approved.iter().collect::>();
+ requested.iter().all(|value| approved.contains(value))
+ }
+ values_are_subset(&requested.connect_domains, &approved.connect_domains)
+ && values_are_subset(&requested.resource_domains, &approved.resource_domains)
+ && values_are_subset(&requested.frame_domains, &approved.frame_domains)
+ && values_are_subset(&requested.base_uri_domains, &approved.base_uri_domains)
+}
+
+fn permissions_are_subset(
+ requested: &McpAppResourcePermissions,
+ approved: &McpAppResourcePermissions,
+) -> bool {
+ (requested.camera.is_none() || approved.camera.is_some())
+ && (requested.microphone.is_none() || approved.microphone.is_some())
+ && (requested.geolocation.is_none() || approved.geolocation.is_some())
+ && (requested.clipboard_write.is_none() || approved.clipboard_write.is_some())
+}
+
+pub(super) fn policy_is_subset(
+ requested: &McpAppResourcePolicy,
+ approved: &McpAppResourcePolicy,
+) -> bool {
+ csp_is_subset(&requested.csp, &approved.csp)
+ && permissions_are_subset(
+ &requested.requested_permissions,
+ &approved.requested_permissions,
+ )
+}
+
+fn sources(values: &[String], fallback: &str) -> String {
+ let collected = values
+ .iter()
+ .filter_map(|value| csp_origin(value))
+ .collect::>();
+ if collected.is_empty() {
+ fallback.to_string()
+ } else {
+ collected.join(" ")
+ }
+}
+
+pub(super) fn sandbox_csp(csp: &McpAppResourceCsp) -> String {
+ let resources = sources(&csp.resource_domains, "");
+ let connects = sources(&csp.connect_domains, "'none'");
+ let frames = sources(&csp.frame_domains, "");
+ let bases = sources(&csp.base_uri_domains, "'self'");
+ format!(
+ "default-src 'none'; script-src 'self' 'unsafe-inline' {resources}; \
+ style-src 'self' 'unsafe-inline' {resources}; img-src 'self' data: blob: {resources}; \
+ font-src 'self' data: {resources}; media-src 'self' data: blob: {resources}; \
+ connect-src {connects}; frame-src 'self' {frames}; base-uri {bases}; \
+ object-src 'none'; form-action 'none'"
+ )
+}
diff --git a/desktop/src-tauri/src/commands/mcp_apps_sandbox_proxy.html b/desktop/src-tauri/src/commands/mcp_apps_sandbox_proxy.html
new file mode 100644
index 0000000000..caf9f98f24
--- /dev/null
+++ b/desktop/src-tauri/src/commands/mcp_apps_sandbox_proxy.html
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/desktop/src-tauri/src/commands/mcp_apps_tests.rs b/desktop/src-tauri/src/commands/mcp_apps_tests.rs
new file mode 100644
index 0000000000..61ea82a023
--- /dev/null
+++ b/desktop/src-tauri/src/commands/mcp_apps_tests.rs
@@ -0,0 +1,990 @@
+use super::*;
+
+#[test]
+fn extracts_nested_and_legacy_ui_resource_uris() {
+ assert_eq!(
+ ui_resource_uri(&json!({"ui": {"resourceUri": "ui://board"}})).as_deref(),
+ Some("ui://board")
+ );
+ assert_eq!(
+ ui_resource_uri(&json!({"ui/resourceUri": "ui://legacy"})).as_deref(),
+ Some("ui://legacy")
+ );
+ assert!(ui_resource_uri(&json!({"ui": {"resourceUri": "https://bad"}})).is_none());
+}
+
+#[test]
+fn app_visibility_defaults_to_model_and_app() {
+ let default = tool_visibility(&json!({}));
+ assert_eq!(default, vec!["model", "app"]);
+ let model_only = tool_visibility(&json!({"ui": {"visibility": ["model"]}}));
+ assert_eq!(model_only, vec!["model"]);
+}
+
+#[test]
+fn caller_visibility_is_enforced() {
+ let tool = McpAppTool {
+ name: "private".to_string(),
+ title: None,
+ description: None,
+ input_schema: json!({}),
+ output_schema: None,
+ annotations: None,
+ meta: json!({}),
+ ui_resource_uri: None,
+ visibility: vec!["app".to_string()],
+ param_headers: Vec::new(),
+ };
+ assert!(app_tool_allowed(&tool, McpAppToolCaller::App));
+ assert!(!app_tool_allowed(&tool, McpAppToolCaller::Host));
+}
+
+#[test]
+fn tool_call_context_is_host_metadata_not_tool_arguments() {
+ let context = McpAppInvocationContext {
+ community_ref: Some("community-1".to_string()),
+ channel_ref: Some("channel-1".to_string()),
+ installation_ref: Some("installation-1".to_string()),
+ };
+ let params = build_tool_call_params(
+ "board.open",
+ json!({"_meta": {"channelRef": "argument-value"}}),
+ None,
+ Some(&context),
+ );
+
+ assert_eq!(
+ params.pointer("/arguments/_meta/channelRef"),
+ Some(&json!("argument-value"))
+ );
+ assert_eq!(
+ params.pointer("/_meta/xyz.block.buzz~1context/communityRef"),
+ Some(&json!("community-1"))
+ );
+ assert_eq!(
+ params.pointer("/_meta/xyz.block.buzz~1context/channelRef"),
+ Some(&json!("channel-1"))
+ );
+ assert_eq!(
+ params.pointer("/_meta/xyz.block.buzz~1context/installationRef"),
+ Some(&json!("installation-1"))
+ );
+}
+
+#[test]
+fn tool_call_context_omits_unavailable_references() {
+ let params = build_tool_call_params(
+ "board.open",
+ json!({}),
+ Some(json!({
+ "progressToken": 7,
+ "xyz.block.buzz/context": {
+ "communityRef": "spoofed",
+ "callerOwned": "discard"
+ }
+ })),
+ Some(&McpAppInvocationContext {
+ community_ref: None,
+ channel_ref: None,
+ installation_ref: None,
+ }),
+ );
+
+ assert_eq!(params.pointer("/_meta/progressToken"), Some(&json!(7)));
+ assert!(params.pointer("/_meta/xyz.block.buzz~1context").is_none());
+}
+
+#[test]
+fn caller_metadata_cannot_override_host_context() {
+ let params = build_tool_call_params(
+ "board.open",
+ json!({}),
+ Some(json!({
+ "xyz.block.buzz/context": {
+ "communityRef": "spoofed-community",
+ "channelRef": "spoofed-channel",
+ "installationRef": "spoofed-installation",
+ "callerOwned": "discard"
+ }
+ })),
+ Some(&McpAppInvocationContext {
+ community_ref: Some("community-1".to_string()),
+ channel_ref: Some("channel-1".to_string()),
+ installation_ref: Some("installation-1".to_string()),
+ }),
+ );
+
+ assert_eq!(
+ params.pointer("/_meta/xyz.block.buzz~1context/communityRef"),
+ Some(&json!("community-1"))
+ );
+ assert_eq!(
+ params.pointer("/_meta/xyz.block.buzz~1context/channelRef"),
+ Some(&json!("channel-1"))
+ );
+ assert_eq!(
+ params.pointer("/_meta/xyz.block.buzz~1context/installationRef"),
+ Some(&json!("installation-1"))
+ );
+ assert!(params
+ .pointer("/_meta/xyz.block.buzz~1context/callerOwned")
+ .is_none());
+}
+
+#[test]
+fn caller_metadata_cannot_claim_the_buzz_host_namespace() {
+ let params = build_tool_call_params(
+ "board.open",
+ json!({}),
+ Some(json!({
+ "progressToken": 9,
+ "xyz.block.buzz/context": {"channelRef": "spoofed"},
+ "xyz.block.buzz/futurePolicy": {"approved": true},
+ "xyz.block.buzzard/context": {"preserved": true}
+ })),
+ Some(&McpAppInvocationContext {
+ community_ref: None,
+ channel_ref: Some("channel-1".to_string()),
+ installation_ref: None,
+ }),
+ );
+
+ assert_eq!(params.pointer("/_meta/progressToken"), Some(&json!(9)));
+ assert_eq!(
+ params.pointer("/_meta/xyz.block.buzz~1context/channelRef"),
+ Some(&json!("channel-1"))
+ );
+ assert!(params
+ .pointer("/_meta/xyz.block.buzz~1futurePolicy")
+ .is_none());
+ assert_eq!(
+ params.pointer("/_meta/xyz.block.buzzard~1context/preserved"),
+ Some(&json!(true))
+ );
+}
+
+#[test]
+fn caller_context_is_removed_when_host_context_is_absent() {
+ let params = build_tool_call_params(
+ "board.open",
+ json!({}),
+ Some(json!({
+ "progressToken": "caller-owned",
+ "xyz.block.buzz/context": {
+ "communityRef": "spoofed-community"
+ }
+ })),
+ None,
+ );
+
+ assert_eq!(
+ params.pointer("/_meta/progressToken"),
+ Some(&json!("caller-owned"))
+ );
+ assert!(params.pointer("/_meta/xyz.block.buzz~1context").is_none());
+}
+
+#[test]
+fn non_object_caller_metadata_is_dropped_without_panicking() {
+ for caller_meta in [
+ json!("not-an-object"),
+ json!(["also", "not", "an", "object"]),
+ ] {
+ let params = build_tool_call_params(
+ "board.open",
+ json!({}),
+ Some(caller_meta),
+ Some(&McpAppInvocationContext {
+ community_ref: Some("community-1".to_string()),
+ channel_ref: None,
+ installation_ref: None,
+ }),
+ );
+
+ assert_eq!(
+ params.pointer("/_meta/xyz.block.buzz~1context/communityRef"),
+ Some(&json!("community-1"))
+ );
+ }
+}
+
+#[test]
+fn endpoint_policy_allows_https_and_loopback_http() {
+ assert!(validate_mcp_endpoint("https://apps.example.com/mcp").is_ok());
+ assert!(validate_mcp_endpoint("https://localhost:1337/mcp").is_ok());
+ assert!(validate_mcp_endpoint("http://127.0.0.1:1337/mcp").is_ok());
+ assert!(validate_mcp_endpoint("http://apps.example.com/mcp").is_err());
+ assert!(validate_mcp_endpoint("https://user:secret@apps.example.com/mcp").is_err());
+}
+
+#[test]
+fn rejects_private_and_transitional_network_addresses() {
+ for address in [
+ "10.0.0.1",
+ "100.64.0.1",
+ "192.0.0.1",
+ "192.168.1.1",
+ "198.18.0.1",
+ "240.0.0.1",
+ "::127.0.0.1",
+ "::ffff:127.0.0.1",
+ "::ffff:0:127.0.0.1",
+ "::ffff:169.254.169.254",
+ "100::1",
+ "64:ff9b::7f00:1",
+ "64:ff9b:1::7f00:1",
+ "2001:2::1",
+ "2001:db8::1",
+ "2002:7f00:1::",
+ "2001::1",
+ "3fff::1",
+ "ff02::1",
+ ] {
+ assert!(
+ is_private_ip(address.parse().unwrap()),
+ "{address} must be rejected"
+ );
+ }
+ assert!(!is_private_ip("2606:4700:4700::1111".parse().unwrap()));
+ assert!(!is_private_ip("3fff:1000::1".parse().unwrap()));
+}
+
+#[test]
+fn csp_drops_invalid_sources_and_defaults_closed() {
+ let csp = sandbox_csp(&McpAppResourceCsp {
+ connect_domains: vec![
+ "https://api.example.com".to_string(),
+ "https://example.com/path".to_string(),
+ ],
+ resource_domains: Vec::new(),
+ frame_domains: Vec::new(),
+ base_uri_domains: Vec::new(),
+ });
+ assert!(csp.contains("connect-src https://api.example.com"));
+ assert!(!csp.contains("https://example.com/path"));
+ assert!(csp.contains("script-src 'self' 'unsafe-inline'"));
+ assert!(csp.contains("img-src 'self' data: blob:"));
+ assert!(csp.contains("frame-src 'self'"));
+ assert!(!csp.contains("frame-src 'self' buzz-mcp-app:"));
+ assert!(csp.contains("object-src 'none'"));
+ assert!(csp.contains("base-uri 'self'"));
+}
+
+#[test]
+fn csp_rejects_bare_wildcards_and_csp_delimiters() {
+ for source in [
+ "https://*",
+ "wss://*",
+ "https://evil.example;x",
+ "https://-bad.example",
+ "https://bad-.example",
+ ] {
+ assert!(csp_origin(source).is_none(), "{source} must be rejected");
+ }
+ assert_eq!(
+ csp_origin("https://*.example.com").as_deref(),
+ Some("https://*.example.com")
+ );
+ assert_eq!(
+ csp_origin("https://api.example.com").as_deref(),
+ Some("https://api.example.com")
+ );
+ assert_eq!(
+ csp_origin("http://[::1]:1337").as_deref(),
+ Some("http://[::1]:1337")
+ );
+ assert_eq!(csp_origin("https://10.0.0.1"), None);
+ assert_eq!(csp_origin("https://169.254.169.254"), None);
+ assert_eq!(csp_origin("https://192.0.0.1"), None);
+ assert_eq!(csp_origin("https://198.18.0.1"), None);
+ assert_eq!(csp_origin("https://240.0.0.1"), None);
+ assert_eq!(csp_origin("https://[fc00::1]"), None);
+ assert_eq!(csp_origin("https://[100::1]"), None);
+ assert_eq!(csp_origin("https://[64:ff9b:1::7f00:1]"), None);
+ assert_eq!(csp_origin("https://[2001:db8::1]"), None);
+ assert_eq!(csp_origin("https://[3fff::1]"), None);
+ assert_eq!(
+ csp_origin("http://127.0.0.1:1337").as_deref(),
+ Some("http://127.0.0.1:1337")
+ );
+}
+
+#[test]
+fn reviewed_policy_allows_only_equal_or_narrower_capabilities() {
+ let approved = McpAppResourcePolicy {
+ csp: McpAppResourceCsp {
+ connect_domains: vec![
+ "https://api.example.com".to_string(),
+ "https://stream.example.com".to_string(),
+ ],
+ resource_domains: vec!["https://cdn.example.com".to_string()],
+ ..Default::default()
+ },
+ requested_permissions: McpAppResourcePermissions {
+ clipboard_write: Some(json!({})),
+ ..Default::default()
+ },
+ };
+ let narrower = McpAppResourcePolicy {
+ csp: McpAppResourceCsp {
+ connect_domains: vec!["https://api.example.com".to_string()],
+ ..Default::default()
+ },
+ requested_permissions: Default::default(),
+ };
+ assert!(policy_is_subset(&narrower, &approved));
+
+ let expanded_domain = McpAppResourcePolicy {
+ csp: McpAppResourceCsp {
+ connect_domains: vec!["https://unreviewed.example.com".to_string()],
+ ..Default::default()
+ },
+ requested_permissions: Default::default(),
+ };
+ assert!(!policy_is_subset(&expanded_domain, &approved));
+
+ let expanded_permission = McpAppResourcePolicy {
+ csp: Default::default(),
+ requested_permissions: McpAppResourcePermissions {
+ camera: Some(json!({})),
+ ..Default::default()
+ },
+ };
+ assert!(!policy_is_subset(&expanded_permission, &approved));
+}
+
+#[test]
+fn sse_parser_assembles_multiline_events_and_matches_request_id() {
+ let event =
+ b"event: message\r\ndata: {\"jsonrpc\":\"2.0\",\r\ndata: \"id\":7,\"result\":{}}\r\n";
+ assert_eq!(
+ sse_event_value(event).unwrap(),
+ Some(json!({"jsonrpc": "2.0", "id": 7, "result": {}}))
+ );
+ assert!(response_matches_id(
+ &sse_event_value(event).unwrap().unwrap(),
+ 7
+ ));
+ assert!(!response_matches_id(
+ &json!({"jsonrpc": "2.0", "method": "notifications/progress"}),
+ 7
+ ));
+}
+
+#[test]
+fn sse_parser_finds_lf_and_crlf_event_boundaries() {
+ assert_eq!(sse_event_end(b"data: {}\n\nnext"), Some((8, 10)));
+ assert_eq!(sse_event_end(b"data: {}\r\n\r\nnext"), Some((8, 12)));
+}
+
+#[test]
+fn sse_parser_skips_notifications_until_the_matching_response() {
+ let mut pending = br#"data: {"jsonrpc":"2.0","method":"notifications/progress"}
+
+data: {"jsonrpc":"2.0","id":9,"result":{"ok":true}}
+
+"#
+ .to_vec();
+ assert_eq!(
+ take_matching_sse_value(&mut pending, Some(9), true).unwrap(),
+ Some(json!({"jsonrpc": "2.0", "id": 9, "result": {"ok": true}}))
+ );
+ assert!(pending.is_empty());
+}
+
+#[test]
+fn inner_app_frame_uses_an_opaque_origin_and_fixed_sandbox() {
+ assert!(SANDBOX_PROXY_HTML.contains(r#"inner.setAttribute("sandbox", "allow-scripts")"#));
+ assert!(SANDBOX_PROXY_HTML.contains("inner.srcdoc = htmlWithCsp(html, csp)"));
+ assert!(SANDBOX_PROXY_HTML.contains("frame-src ${frames}"));
+ assert!(SANDBOX_PROXY_HTML.contains("sources(csp?.frameDomains, \"'none'\")"));
+ assert!(SANDBOX_PROXY_HTML.contains("sources(csp?.baseUriDomains, \"'self'\")"));
+ assert!(!SANDBOX_PROXY_HTML.contains("new URL(document.referrer).origin"));
+ assert!(!SANDBOX_PROXY_HTML.contains("http://localhost:1420"));
+ assert!(!SANDBOX_PROXY_HTML.contains("http://127.0.0.1:1420"));
+ #[cfg(debug_assertions)]
+ {
+ let debug_proxy = sandbox_proxy_html();
+ assert!(debug_proxy.contains("http://localhost:1420"));
+ assert!(debug_proxy.contains("http://127.0.0.1:1420"));
+ }
+ assert!(!SANDBOX_PROXY_HTML.contains("allow-same-origin allow-forms"));
+ assert!(!SANDBOX_PROXY_HTML.contains("inner.setAttribute(\"sandbox\", sandbox)"));
+}
+
+#[test]
+fn sandbox_url_uses_the_platform_custom_protocol_form() {
+ assert_eq!(
+ sandbox_url_for_platform("view-id", false),
+ "buzz-mcp-app://localhost/view-id"
+ );
+ assert_eq!(
+ sandbox_url_for_platform("view-id", true),
+ "http://buzz-mcp-app.localhost/view-id"
+ );
+}
+
+#[test]
+fn parses_text_ui_resource_and_metadata() {
+ let response = json!({
+ "result": {
+ "contents": [{
+ "uri": "ui://board",
+ "mimeType": MCP_APP_MIME_TYPE,
+ "text": "Board ",
+ "_meta": {
+ "ui": {
+ "csp": {"connectDomains": ["https://api.example.com"]},
+ "permissions": {"clipboardWrite": {}}
+ }
+ }
+ }]
+ }
+ });
+ let (html, csp, permissions) = parse_ui_resource(&response, "ui://board", None).unwrap();
+ assert_eq!(html, "Board ");
+ assert_eq!(csp.connect_domains, vec!["https://api.example.com"]);
+ assert!(permissions.clipboard_write.is_some());
+}
+
+#[test]
+fn resource_read_policy_takes_precedence_over_listing_metadata() {
+ let listing = McpAppResource {
+ uri: "ui://board".to_string(),
+ name: None,
+ title: None,
+ description: None,
+ mime_type: Some(MCP_APP_MIME_TYPE.to_string()),
+ meta: json!({
+ "ui": {
+ "csp": {"connectDomains": ["https://listing.example.com"]}
+ }
+ }),
+ };
+ let response = json!({
+ "result": {
+ "contents": [{
+ "uri": "ui://board",
+ "mimeType": MCP_APP_MIME_TYPE,
+ "text": "Board ",
+ "_meta": {
+ "ui": {
+ "csp": {"connectDomains": ["https://read.example.com"]}
+ }
+ }
+ }]
+ }
+ });
+ let (_, csp, _) = parse_ui_resource(&response, "ui://board", Some(&listing)).unwrap();
+ assert_eq!(csp.connect_domains, vec!["https://read.example.com"]);
+}
+
+#[test]
+fn ui_resource_csp_is_sanitized_before_reaching_the_proxy() {
+ let response = json!({
+ "result": {
+ "contents": [{
+ "uri": "ui://board",
+ "mimeType": MCP_APP_MIME_TYPE,
+ "text": "Board ",
+ "_meta": {
+ "ui": {
+ "csp": {
+ "connectDomains": [
+ "https://api.example.com",
+ "wss://stream.example.com",
+ "http://public.example.com",
+ "https://example.com/path"
+ ],
+ "frameDomains": ["https://video.example.com"]
+ }
+ }
+ }
+ }]
+ }
+ });
+ let (_, csp, _) = parse_ui_resource(&response, "ui://board", None).unwrap();
+ assert_eq!(
+ csp.connect_domains,
+ vec!["https://api.example.com", "wss://stream.example.com"]
+ );
+ assert_eq!(csp.frame_domains, vec!["https://video.example.com"]);
+}
+
+#[test]
+fn modern_request_carries_version_method_and_meta() {
+ let params = prepare_params(McpEra::Modern, json!({}), MCP_MODERN_PROTOCOL_VERSION);
+ let payload = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": params});
+ let headers = build_mcp_headers(
+ McpEra::Modern,
+ Some(MCP_MODERN_PROTOCOL_VERSION),
+ None,
+ &payload,
+ &[],
+ );
+ assert!(headers.contains(&("MCP-Protocol-Version".to_string(), "2026-07-28".to_string())));
+ assert!(headers.contains(&("Mcp-Method".to_string(), "tools/list".to_string())));
+ assert!(headers
+ .iter()
+ .all(|(name, _)| name.as_str() != "mcp-session-id"));
+ let meta = payload
+ .pointer("/params/_meta")
+ .expect("modern params must carry _meta");
+ assert_eq!(
+ meta.get("io.modelcontextprotocol/protocolVersion"),
+ Some(&json!(MCP_MODERN_PROTOCOL_VERSION))
+ );
+ let mime_types = meta
+ .get("io.modelcontextprotocol/clientCapabilities")
+ .and_then(|caps| caps.pointer("/extensions/io.modelcontextprotocol~1ui/mimeTypes"))
+ .expect("capabilities must declare the ui extension");
+ assert_eq!(mime_types, &json!([MCP_APP_MIME_TYPE]));
+ assert!(meta.get("io.modelcontextprotocol/clientInfo").is_some());
+}
+
+#[test]
+fn modern_tool_call_preserves_buzz_context_and_required_protocol_metadata() {
+ let context = McpAppInvocationContext {
+ community_ref: Some("community-1".to_string()),
+ channel_ref: Some("channel-1".to_string()),
+ installation_ref: Some("installation-1".to_string()),
+ };
+ let params = prepare_params(
+ McpEra::Modern,
+ build_tool_call_params("board.open", json!({}), None, Some(&context)),
+ MCP_MODERN_PROTOCOL_VERSION,
+ );
+
+ assert_eq!(
+ params.pointer("/_meta/xyz.block.buzz~1context"),
+ Some(&json!({
+ "communityRef": "community-1",
+ "channelRef": "channel-1",
+ "installationRef": "installation-1"
+ }))
+ );
+ let meta = params
+ .get("_meta")
+ .and_then(Value::as_object)
+ .expect("modern params must carry _meta");
+ for key in [
+ "io.modelcontextprotocol/protocolVersion",
+ "io.modelcontextprotocol/clientCapabilities",
+ "io.modelcontextprotocol/clientInfo",
+ ] {
+ assert!(meta.contains_key(key), "modern metadata is missing {key}");
+ }
+}
+
+#[test]
+fn legacy_request_keeps_session_and_omits_modern_extras() {
+ let params = prepare_params(
+ McpEra::Legacy,
+ json!({"cursor": "abc"}),
+ MCP_PROTOCOL_VERSION,
+ );
+ assert_eq!(params, json!({"cursor": "abc"}));
+ let payload = json!({"jsonrpc": "2.0", "id": 7, "method": "tools/list", "params": params});
+ let headers = build_mcp_headers(
+ McpEra::Legacy,
+ Some(MCP_PROTOCOL_VERSION),
+ Some("session-123"),
+ &payload,
+ &[],
+ );
+ assert!(headers.contains(&(
+ "mcp-protocol-version".to_string(),
+ MCP_PROTOCOL_VERSION.to_string()
+ )));
+ assert!(headers.contains(&("mcp-session-id".to_string(), "session-123".to_string())));
+ assert!(headers.iter().all(|(name, _)| {
+ name.as_str() != "Mcp-Method"
+ && name.as_str() != "MCP-Protocol-Version"
+ && name.as_str() != "Mcp-Name"
+ }));
+}
+
+#[test]
+fn mcp_name_header_targets_name_addressed_methods() {
+ let call = json!({
+ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
+ "params": {"name": "board.update", "arguments": {}}
+ });
+ let headers = build_mcp_headers(
+ McpEra::Modern,
+ Some(MCP_MODERN_PROTOCOL_VERSION),
+ None,
+ &call,
+ &[],
+ );
+ assert!(headers.contains(&("Mcp-Name".to_string(), "board.update".to_string())));
+
+ let read = json!({
+ "jsonrpc": "2.0", "id": 2, "method": "resources/read",
+ "params": {"uri": "ui://board"}
+ });
+ let headers = build_mcp_headers(
+ McpEra::Modern,
+ Some(MCP_MODERN_PROTOCOL_VERSION),
+ None,
+ &read,
+ &[],
+ );
+ assert!(headers.contains(&("Mcp-Name".to_string(), "ui://board".to_string())));
+
+ let list = json!({"jsonrpc": "2.0", "id": 3, "method": "tools/list", "params": {}});
+ let headers = build_mcp_headers(
+ McpEra::Modern,
+ Some(MCP_MODERN_PROTOCOL_VERSION),
+ None,
+ &list,
+ &[],
+ );
+ assert!(headers.iter().all(|(name, _)| name.as_str() != "Mcp-Name"));
+}
+
+#[test]
+fn non_ascii_mcp_name_uses_base64_sentinel() {
+ assert_eq!(mcp_name_header_value("board.update"), "board.update");
+ assert_eq!(mcp_name_header_value("café"), "=?base64?Y2Fmw6k=?=");
+ let call = json!({
+ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
+ "params": {"name": "café", "arguments": {}}
+ });
+ let headers = build_mcp_headers(
+ McpEra::Modern,
+ Some(MCP_MODERN_PROTOCOL_VERSION),
+ None,
+ &call,
+ &[],
+ );
+ assert!(headers.contains(&("Mcp-Name".to_string(), "=?base64?Y2Fmw6k=?=".to_string())));
+}
+
+#[test]
+fn valid_nested_tool_parameters_are_mirrored_into_headers() {
+ let response = json!({
+ "result": {
+ "tools": [{
+ "name": "board.update",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "context": {
+ "type": "object",
+ "properties": {
+ "space": {
+ "type": "string",
+ "x-mcp-header": "Space"
+ }
+ }
+ },
+ "priority": {
+ "type": "integer",
+ "x-mcp-header": "Priority"
+ },
+ "approved": {
+ "type": "boolean",
+ "x-mcp-header": "Approved"
+ }
+ }
+ }
+ }]
+ }
+ });
+ let tools = parse_tools(&response).unwrap();
+ let params = json!({
+ "name": "board.update",
+ "arguments": {
+ "context": {"space": "product"},
+ "priority": 7,
+ "approved": true
+ }
+ });
+ let headers = tool_param_headers(&tools, "tools/call", ¶ms).unwrap();
+ assert_eq!(headers.len(), 3);
+ assert!(headers.contains(&("Mcp-Param-Space".to_string(), "product".to_string())));
+ assert!(headers.contains(&("Mcp-Param-Priority".to_string(), "7".to_string())));
+ assert!(headers.contains(&("Mcp-Param-Approved".to_string(), "true".to_string())));
+}
+
+#[test]
+fn invalid_parameter_header_annotations_exclude_only_that_tool() {
+ let response = json!({
+ "result": {
+ "tools": [
+ {
+ "name": "invalid",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "first": {"type": "string", "x-mcp-header": "Tenant"},
+ "second": {"type": "string", "x-mcp-header": "tenant"}
+ }
+ }
+ },
+ {
+ "name": "also-invalid",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "ratio": {"type": "number", "x-mcp-header": "Ratio"}
+ }
+ }
+ },
+ {
+ "name": "valid",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "region": {"type": "string", "x-mcp-header": "Region"}
+ }
+ }
+ }
+ ]
+ }
+ });
+ let tools = parse_tools(&response).unwrap();
+ assert_eq!(
+ tools
+ .iter()
+ .map(|tool| tool.name.as_str())
+ .collect::>(),
+ vec!["valid"]
+ );
+}
+
+#[test]
+fn missing_and_null_parameter_headers_are_omitted() {
+ let response = json!({
+ "result": {
+ "tools": [{
+ "name": "query",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "region": {"type": "string", "x-mcp-header": "Region"},
+ "tenant": {"type": "string", "x-mcp-header": "Tenant"}
+ }
+ }
+ }]
+ }
+ });
+ let tools = parse_tools(&response).unwrap();
+ let headers = tool_param_headers(
+ &tools,
+ "tools/call",
+ &json!({"name": "query", "arguments": {"region": null}}),
+ )
+ .unwrap();
+ assert!(headers.is_empty());
+}
+
+#[test]
+fn unsafe_parameter_header_values_use_the_base64_sentinel() {
+ assert_eq!(
+ mcp_param_header_value(&json!("hello world")).unwrap(),
+ Some("hello world".to_string())
+ );
+ assert_eq!(
+ mcp_param_header_value(&json!(" padded ")).unwrap(),
+ Some("=?base64?IHBhZGRlZCA=?=".to_string())
+ );
+ assert_eq!(
+ mcp_param_header_value(&json!("line1\nline2")).unwrap(),
+ Some("=?base64?bGluZTEKbGluZTI=?=".to_string())
+ );
+ assert_eq!(
+ mcp_param_header_value(&json!("=?base64?literal?=")).unwrap(),
+ Some("=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=".to_string())
+ );
+ assert_eq!(
+ mcp_param_header_value(&json!("=?BASE64?bGluZTEKbGluZTI=?=")).unwrap(),
+ Some("=?base64?PT9CQVNFNjQ/YkdsdVpURUtiR2x1WlRJPT89?=".to_string())
+ );
+ assert_eq!(
+ mcp_param_header_value(&json!("=?future-sentinel")).unwrap(),
+ Some("=?base64?PT9mdXR1cmUtc2VudGluZWw=?=".to_string())
+ );
+}
+
+#[test]
+fn rpc_errors_are_bounded_and_do_not_echo_server_data() {
+ let error = json!({
+ "code": -32000,
+ "message": "x".repeat(MAX_MCP_ERROR_MESSAGE_CHARS + 10),
+ "data": {"secret": "must not be shown"}
+ });
+ let rendered = format_rpc_error(&error);
+ assert!(rendered.starts_with("code -32000: "));
+ assert!(rendered.ends_with('…'));
+ assert!(!rendered.contains("secret"));
+ assert!(rendered.chars().count() <= MAX_MCP_ERROR_MESSAGE_CHARS + 20);
+}
+
+#[test]
+fn era_probe_classifies_modern_errors_and_legacy_fallback() {
+ let ok = reqwest::StatusCode::OK;
+ let bad_request = reqwest::StatusCode::BAD_REQUEST;
+ let result = json!({"jsonrpc": "2.0", "id": 1, "result": {"tools": []}});
+ assert_eq!(classify_probe(ok, Some(&result)), ProbeOutcome::Modern);
+
+ let unsupported = json!({
+ "jsonrpc": "2.0", "id": 1,
+ "error": {"code": -32022, "message": "unsupported", "data": {"supported": ["2026-07-28"]}}
+ });
+ assert_eq!(
+ classify_probe(bad_request, Some(&unsupported)),
+ ProbeOutcome::ModernRetry {
+ supported: vec!["2026-07-28".to_string()]
+ }
+ );
+
+ let mismatch = json!({
+ "jsonrpc": "2.0", "id": 1,
+ "error": {"code": -32020, "message": "header mismatch"}
+ });
+ assert!(matches!(
+ classify_probe(bad_request, Some(&mismatch)),
+ ProbeOutcome::ModernError { .. }
+ ));
+
+ let legacy_error = json!({
+ "jsonrpc": "2.0", "id": 1,
+ "error": {"code": -32000, "message": "server not initialized"}
+ });
+ assert_eq!(
+ classify_probe(bad_request, Some(&legacy_error)),
+ ProbeOutcome::Legacy
+ );
+ assert_eq!(classify_probe(bad_request, None), ProbeOutcome::Legacy);
+ assert_eq!(
+ classify_probe(reqwest::StatusCode::NOT_FOUND, None),
+ ProbeOutcome::Legacy
+ );
+ assert_eq!(
+ classify_probe(reqwest::StatusCode::METHOD_NOT_ALLOWED, None),
+ ProbeOutcome::Legacy
+ );
+}
+
+#[test]
+fn absent_result_type_reads_as_complete() {
+ assert_eq!(result_completion(&json!({"tools": []})), "complete");
+ assert_eq!(
+ result_completion(&json!({"resultType": "partial"})),
+ "partial"
+ );
+ let response = json!({
+ "result": {"resultType": "complete", "ttlMs": 5000, "cacheScope": "origin", "content": []}
+ });
+ let result =
+ extract_result(&response, "tools/call").expect("advisory fields must be tolerated");
+ assert_eq!(result.get("content"), Some(&json!([])));
+}
+
+#[test]
+fn resource_not_found_accepts_both_error_codes() {
+ assert!(is_resource_not_found(
+ &json!({"code": -32002, "message": "not found"})
+ ));
+ assert!(is_resource_not_found(
+ &json!({"code": -32602, "message": "not found"})
+ ));
+ assert!(!is_resource_not_found(
+ &json!({"code": -32000, "message": "other"})
+ ));
+}
+
+#[test]
+fn rejects_ui_resource_uri_mismatch() {
+ let response = json!({
+ "result": {
+ "contents": [{
+ "uri": "ui://other",
+ "mimeType": MCP_APP_MIME_TYPE,
+ "text": "Other "
+ }]
+ }
+ });
+ assert!(parse_ui_resource(&response, "ui://board", None).is_err());
+}
+
+#[test]
+fn generic_invalid_params_does_not_classify_a_legacy_server_as_modern() {
+ // -32602 is standard JSON-RPC "Invalid params", not a modern-only code. A
+ // 2025-11-25 server rejecting our modern probe must still fall back to the
+ // initialize handshake rather than hard-failing the connection.
+ assert!(matches!(
+ classify_probe(
+ reqwest::StatusCode::BAD_REQUEST,
+ Some(
+ &json!({"jsonrpc": "2.0", "error": {"code": -32602, "message": "Invalid params"}})
+ )
+ ),
+ ProbeOutcome::Legacy
+ ));
+ // A genuinely modern-only code still classifies as modern.
+ assert!(!matches!(
+ classify_probe(
+ reqwest::StatusCode::BAD_REQUEST,
+ Some(
+ &json!({"jsonrpc": "2.0", "error": {"code": -32021, "message": "missing capability"}})
+ )
+ ),
+ ProbeOutcome::Legacy
+ ));
+}
+
+#[test]
+fn control_characters_never_reach_a_raw_mcp_name_header() {
+ // Tool names and resource URIs are server-authored. Any byte outside
+ // printable-ASCII must take the base64 sentinel path so CR/LF can never
+ // split headers.
+ for raw in [
+ "evil\r\nX-Injected: 1",
+ "a\nb",
+ "a\rb",
+ "a\u{0000}b",
+ "a b",
+ "tab\there",
+ "del\u{007f}",
+ "=?base64?spoof?=",
+ "café",
+ ] {
+ let value = mcp_name_header_value(raw);
+ assert!(
+ value.starts_with("=?base64?") && value.ends_with("?="),
+ "{raw:?} must be sentinel-encoded, got {value:?}"
+ );
+ assert!(
+ !value.contains(['\r', '\n', '\0']),
+ "{raw:?} produced an unsafe header value"
+ );
+ }
+ // Ordinary header-safe names stay plain.
+ assert_eq!(mcp_name_header_value("weather.get"), "weather.get");
+ assert_eq!(mcp_name_header_value("ui://app/board"), "ui://app/board");
+}
+
+#[test]
+fn probe_falls_back_to_legacy_without_a_recognized_modern_error() {
+ // A legacy server may answer the sessionless probe with 5xx or an
+ // unrecognized error. Both must fall back to the handshake, not hard-fail.
+ for status in [
+ reqwest::StatusCode::INTERNAL_SERVER_ERROR,
+ reqwest::StatusCode::UNAUTHORIZED,
+ reqwest::StatusCode::BAD_REQUEST,
+ ] {
+ assert!(matches!(classify_probe(status, None), ProbeOutcome::Legacy));
+ }
+ assert!(matches!(
+ classify_probe(
+ reqwest::StatusCode::OK,
+ Some(
+ &json!({"jsonrpc": "2.0", "error": {"code": -32000, "message": "not initialized"}})
+ )
+ ),
+ ProbeOutcome::Legacy
+ ));
+}
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 66ef7ef17b..b858355a99 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -24,6 +24,7 @@ mod identity_archive;
mod join_policy;
mod legacy_storage;
mod link_preview;
+mod mcp_apps;
pub(crate) mod media;
mod media_animated;
mod media_download;
@@ -83,6 +84,7 @@ pub use identity_archive::*;
pub use join_policy::*;
pub use legacy_storage::*;
pub use link_preview::*;
+pub use mcp_apps::*;
pub use media::*;
pub use media_download::*;
#[cfg(feature = "mesh-llm")]
diff --git a/desktop/src-tauri/src/huddle/agent_tts_routing.rs b/desktop/src-tauri/src/huddle/agent_tts_routing.rs
new file mode 100644
index 0000000000..2ee3ec0d41
--- /dev/null
+++ b/desktop/src-tauri/src/huddle/agent_tts_routing.rs
@@ -0,0 +1,56 @@
+use super::HuddlePhase;
+
+#[derive(Debug, PartialEq, Eq)]
+pub(super) enum AgentTtsRuntimeGate {
+ Disabled,
+ Inactive,
+ NeedsPipeline,
+ Ready,
+}
+
+pub(super) fn classify_agent_tts_runtime(
+ enabled: bool,
+ phase: &HuddlePhase,
+ has_pipeline: bool,
+) -> AgentTtsRuntimeGate {
+ if !enabled {
+ AgentTtsRuntimeGate::Disabled
+ } else if !matches!(phase, HuddlePhase::Connected | HuddlePhase::Active) {
+ AgentTtsRuntimeGate::Inactive
+ } else if has_pipeline {
+ AgentTtsRuntimeGate::Ready
+ } else {
+ AgentTtsRuntimeGate::NeedsPipeline
+ }
+}
+
+/// Maximum text length accepted for TTS synthesis.
+/// ~2000 chars is 1–2 minutes of speech. Longer messages are truncated.
+pub(super) const MAX_TTS_TEXT_LEN: usize = 2000;
+
+pub(super) fn normalize_agent_tts_text(text: String) -> String {
+ if text.chars().count() > MAX_TTS_TEXT_LEN {
+ let mut truncated: String = text.chars().take(MAX_TTS_TEXT_LEN).collect();
+ truncated.push_str("... message truncated.");
+ truncated
+ } else {
+ text
+ }
+}
+
+pub(super) async fn enqueue_agent_tts_text(
+ route_id: u64,
+ text: String,
+ enqueue: F,
+) -> Result<(), String>
+where
+ F: FnOnce(u64, String) -> Result<(), String> + Send + 'static,
+{
+ tokio::task::spawn_blocking(move || enqueue(route_id, text))
+ .await
+ .map_err(|error| format!("TTS enqueue task failed: {error}"))?
+}
+
+#[cfg(test)]
+#[path = "agent_tts_routing_tests.rs"]
+mod tests;
diff --git a/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs b/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs
new file mode 100644
index 0000000000..cb550d7005
--- /dev/null
+++ b/desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs
@@ -0,0 +1,57 @@
+use super::{
+ classify_agent_tts_runtime, enqueue_agent_tts_text, normalize_agent_tts_text,
+ AgentTtsRuntimeGate, MAX_TTS_TEXT_LEN,
+};
+use crate::huddle::HuddlePhase;
+
+#[tokio::test]
+async fn assistant_plain_text_routes_unchanged_into_voice_pipeline_boundary() {
+ let (sender, receiver) = std::sync::mpsc::channel();
+ let text = "A newly submitted assistant reply.".to_string();
+ let route_id = 42;
+
+ enqueue_agent_tts_text(route_id, text.clone(), move |route_id, queued| {
+ sender
+ .send((route_id, queued))
+ .map_err(|error| error.to_string())
+ })
+ .await
+ .expect("route assistant text");
+
+ assert_eq!(
+ receiver.recv().expect("queued text"),
+ (route_id, text),
+ "route correlation must survive the native queue boundary"
+ );
+}
+
+#[test]
+fn disabled_is_the_only_intentional_runtime_no_op() {
+ assert_eq!(
+ classify_agent_tts_runtime(false, &HuddlePhase::Connected, false),
+ AgentTtsRuntimeGate::Disabled
+ );
+ assert_eq!(
+ classify_agent_tts_runtime(true, &HuddlePhase::Idle, false),
+ AgentTtsRuntimeGate::Inactive
+ );
+ assert_eq!(
+ classify_agent_tts_runtime(true, &HuddlePhase::Connected, false),
+ AgentTtsRuntimeGate::NeedsPipeline
+ );
+ assert_eq!(
+ classify_agent_tts_runtime(true, &HuddlePhase::Connected, true),
+ AgentTtsRuntimeGate::Ready
+ );
+}
+
+#[test]
+fn assistant_text_truncation_is_unicode_safe_before_voice_routing() {
+ let input = "🦀".repeat(MAX_TTS_TEXT_LEN + 1);
+ let output = normalize_agent_tts_text(input);
+ assert_eq!(
+ output.chars().count(),
+ MAX_TTS_TEXT_LEN + "... message truncated.".chars().count()
+ );
+ assert!(output.ends_with("... message truncated."));
+}
diff --git a/desktop/src-tauri/src/huddle/agents.rs b/desktop/src-tauri/src/huddle/agents.rs
index 02c4045410..2de22f99d8 100644
--- a/desktop/src-tauri/src/huddle/agents.rs
+++ b/desktop/src-tauri/src/huddle/agents.rs
@@ -2,7 +2,8 @@
//!
//! Mental model:
//! add_agent_to_huddle → kind:9000 to ephemeral channel
-//! → kind:9000 to parent channel (best-effort)
+//! → preserve existing parent membership, or
+//! kind:9000 to parent channel (best-effort)
//!
//! ACP spawning is NOT needed here: the running agent process auto-subscribes
//! when it receives the kind:9000 membership notification. Huddle-specific
@@ -11,7 +12,10 @@
use serde::Serialize;
use uuid::Uuid;
-use crate::{app_state::AppState, events, relay::submit_event};
+use crate::{
+ app_state::AppState, events, huddle::relay_api::fetch_channel_members_with_roles,
+ relay::submit_event,
+};
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -61,8 +65,9 @@ with the next one.
/// The field exists for forward compatibility with future batch-add operations
/// where partial success may be meaningful.
///
-/// `parent_added` reflects whether the parent-channel add succeeded;
-/// `parent_error` carries the error string when it didn't.
+/// `parent_added` reflects whether the parent already contained the agent or
+/// the parent-channel add succeeded; `parent_error` carries the error string
+/// when neither condition could be confirmed.
#[derive(Debug, Serialize)]
pub struct AgentAddResult {
/// Always `true` — invariant guaranteed by [`add_agent_to_huddle`].
@@ -91,17 +96,33 @@ pub async fn add_agent_to_huddle(
let add_eph = events::build_add_member(ephemeral_channel_id, agent_pubkey, Some("bot"))?;
submit_event(add_eph, state).await?;
- // 2. Add agent to parent channel — so agent has full context.
- // Best-effort: capture the error but don't propagate it.
- let (parent_added, parent_error) = {
+ // 2. Preserve any active parent membership, regardless of role. Rewriting
+ // an existing DM member as `bot` is both unnecessary and forbidden for
+ // non-admins. Otherwise add the agent so it has full context.
+ // Best-effort: capture a real error but don't propagate it.
+ let parent_channel_id_string = parent_channel_id.to_string();
+ let parent_already_contains_agent =
+ fetch_channel_members_with_roles(&parent_channel_id_string, state)
+ .await
+ .is_ok_and(|members| contains_member(&members, agent_pubkey));
+
+ let (parent_added, parent_error) = if parent_already_contains_agent {
+ (true, None)
+ } else {
let add_parent = events::build_add_member(parent_channel_id, agent_pubkey, Some("bot"))?;
match submit_event(add_parent, state).await {
Ok(_) => (true, None),
Err(e) => {
- eprintln!(
- "buzz-desktop: add agent to parent channel failed (may already be member): {e}"
- );
- (false, Some(e))
+ let active_after_error =
+ fetch_channel_members_with_roles(&parent_channel_id_string, state)
+ .await
+ .is_ok_and(|members| contains_member(&members, agent_pubkey));
+ if active_after_error {
+ (true, None)
+ } else {
+ eprintln!("buzz-desktop: add agent to parent channel failed: {e}");
+ (false, Some(e))
+ }
}
}
};
@@ -112,3 +133,26 @@ pub async fn add_agent_to_huddle(
parent_error,
})
}
+
+fn contains_member(members: &[(String, Option)], pubkey: &str) -> bool {
+ members
+ .iter()
+ .any(|(member_pubkey, _)| member_pubkey.eq_ignore_ascii_case(pubkey))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::contains_member;
+
+ #[test]
+ fn existing_parent_membership_is_preserved_regardless_of_role() {
+ let members = vec![
+ ("agent-member".to_owned(), Some("member".to_owned())),
+ ("agent-bot".to_owned(), Some("bot".to_owned())),
+ ];
+
+ assert!(contains_member(&members, "AGENT-MEMBER"));
+ assert!(contains_member(&members, "agent-bot"));
+ assert!(!contains_member(&members, "missing"));
+ }
+}
diff --git a/desktop/src-tauri/src/huddle/audio_output.rs b/desktop/src-tauri/src/huddle/audio_output.rs
index dbd09353db..34dec53094 100644
--- a/desktop/src-tauri/src/huddle/audio_output.rs
+++ b/desktop/src-tauri/src/huddle/audio_output.rs
@@ -39,7 +39,8 @@ fn list_audio_output_devices_blocking() -> Result, String
#[tauri::command]
pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Result<(), String> {
let mut guard = state
- .audio_output_device
+ .huddle_audio
+ .output_device
.lock()
.map_err(|e| e.to_string())?;
*guard = if name.is_empty() { None } else { Some(name) };
@@ -50,7 +51,8 @@ pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Resu
#[tauri::command]
pub fn get_audio_output_device(state: State<'_, AppState>) -> Result {
let guard = state
- .audio_output_device
+ .huddle_audio
+ .output_device
.lock()
.map_err(|e| e.to_string())?;
Ok(guard.clone().unwrap_or_default())
diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs
index a815bf2d06..03264f80f4 100644
--- a/desktop/src-tauri/src/huddle/mod.rs
+++ b/desktop/src-tauri/src/huddle/mod.rs
@@ -23,6 +23,7 @@
//! takes `stt_pipeline`/`tts_pipeline` out of the lock, then calls `shutdown()`
//! and drops them outside the lock (thread joins can block ~200ms).
+mod agent_tts_routing;
pub mod agents;
pub mod audio_output;
pub mod jitter;
@@ -37,6 +38,9 @@ pub mod state;
pub mod stt;
pub mod transcription;
pub mod tts;
+pub mod tts_settings;
+mod tts_voice_import;
+mod tts_voice_registry;
pub mod wire;
// ── Shared utilities ──────────────────────────────────────────────────────────
@@ -63,16 +67,25 @@ pub(super) fn drain_until_shutdown(
pub use state::{HuddleJoinInfo, HuddlePhase, HuddleState, VoiceInputMode};
pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline};
+pub use tts_settings::set_tts_enabled;
// ── Imports ───────────────────────────────────────────────────────────────────
-use std::sync::{atomic::Ordering, Arc};
+use std::sync::atomic::Ordering;
use tauri::State;
use uuid::Uuid;
use crate::{app_state::AppState, events, relay::submit_event};
-use pipeline::{maybe_start_stt_pipeline, maybe_start_tts_pipeline, post_connect_setup};
+use agent_tts_routing::{
+ classify_agent_tts_runtime, enqueue_agent_tts_text, normalize_agent_tts_text,
+ AgentTtsRuntimeGate,
+};
+pub use pipeline::check_pipeline_hotstart;
+use pipeline::{
+ await_inflight_tts_start, maybe_start_stt_pipeline, maybe_start_tts_pipeline,
+ post_connect_setup, start_auto_enabled_transcription, PostConnectOutcome,
+};
use relay_api::{
count_human_members, fetch_channel_members, parse_channel_uuid, validate_pubkey_hex,
MAX_HUDDLE_AGENTS,
@@ -186,7 +199,7 @@ pub async fn start_huddle(
};
// Transition to Creating.
- {
+ let huddle_generation = {
let mut hs = state.huddle()?;
if hs.phase != HuddlePhase::Idle {
return Err(format!(
@@ -194,9 +207,11 @@ pub async fn start_huddle(
hs.phase
));
}
+ let generation = hs.begin_huddle_lifetime();
hs.phase = HuddlePhase::Creating;
hs.parent_channel_id = Some(parent_channel_id.clone());
- }
+ generation
+ };
let ephemeral_uuid = Uuid::new_v4();
let ephemeral_channel_id = ephemeral_uuid.to_string();
@@ -259,27 +274,33 @@ pub async fn start_huddle(
match result {
Ok(successful_agents) => {
// 5. Store active state.
- {
+ let committed = {
let mut hs = state.huddle()?;
- hs.phase = HuddlePhase::Connected;
- hs.is_creator = true;
- hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone());
- // Only store agents that were successfully enrolled.
- *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) =
- successful_agents.clone();
- // Include the current user + successfully enrolled agents as participants.
- // Use successful_agents (not member_pubkeys) so failed enrollments
- // are not reflected in the participant list.
- let own_pubkey = state
- .keys
- .lock()
- .map(|k| k.public_key().to_hex())
- .unwrap_or_default();
- let mut participants = successful_agents.clone();
- if !own_pubkey.is_empty() && !participants.contains(&own_pubkey) {
- participants.insert(0, own_pubkey);
+ if !hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Creating) {
+ false
+ } else {
+ hs.phase = HuddlePhase::Connected;
+ hs.is_creator = true;
+ hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone());
+ *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) =
+ successful_agents.clone();
+ hs.maybe_auto_enable_transcription_for_agents();
+ let own_pubkey = state
+ .keys
+ .lock()
+ .map(|k| k.public_key().to_hex())
+ .unwrap_or_default();
+ let mut participants = successful_agents.clone();
+ if !own_pubkey.is_empty() && !participants.contains(&own_pubkey) {
+ participants.insert(0, own_pubkey);
+ }
+ hs.participants = participants;
+ true
}
- hs.participants = participants;
+ };
+ if !committed {
+ emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await;
+ return Err("huddle start was superseded".to_owned());
}
// 6. Notify frontend of state change.
@@ -287,16 +308,30 @@ pub async fn start_huddle(
// 7. Hydrate members, download models, start pipelines (incl. audio relay).
// Audio relay failure is fatal — no point in a huddle without audio.
- if let Err(e) = post_connect_setup(&state, &ephemeral_channel_id).await {
- // Rollback: audio relay failed after state was committed.
- // Publish the terminal lifecycle event before archiving so
- // other clients do not reconstruct a phantom active huddle.
- emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await;
- if let Ok(mut hs) = state.huddle_state.lock() {
- hs.reset_preserving_generation();
+ match post_connect_setup(&state, &ephemeral_channel_id, huddle_generation).await {
+ Ok(PostConnectOutcome::Ready) => {}
+ Ok(PostConnectOutcome::Stale) => {
+ return Err("huddle start was superseded".to_owned());
+ }
+ Err(e) => {
+ // Roll back only if this failed setup still owns the active
+ // huddle. A stale failure must not tear down its replacement.
+ let still_current = state
+ .huddle()
+ .map(|hs| hs.is_current_huddle(&ephemeral_channel_id, huddle_generation))
+ .unwrap_or(false);
+ if still_current {
+ emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state)
+ .await;
+ if let Ok(mut hs) = state.huddle_state.lock() {
+ if hs.is_current_huddle(&ephemeral_channel_id, huddle_generation) {
+ hs.reset_preserving_generation();
+ }
+ }
+ state.emit_huddle_state_changed();
+ }
+ return Err(e);
}
- state.emit_huddle_state_changed();
- return Err(e);
}
Ok(HuddleJoinInfo {
@@ -314,11 +349,11 @@ pub async fn start_huddle(
}
}
}
- // Reset state to Idle so the user can retry.
- // Preserve session_generation so in-flight transcription tasks
- // from a prior session still see a stale generation and exit.
+ // Reset only if this failed attempt still owns the Creating state.
if let Ok(mut hs) = state.huddle_state.lock() {
- hs.reset_preserving_generation();
+ if hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Creating) {
+ hs.reset_preserving_generation();
+ }
}
Err(e)
}
@@ -340,7 +375,7 @@ pub async fn join_huddle(
state: State<'_, AppState>,
) -> Result {
// Transition to Connecting.
- {
+ let huddle_generation = {
let mut hs = state.huddle()?;
if hs.phase != HuddlePhase::Idle {
return Err(format!(
@@ -348,10 +383,12 @@ pub async fn join_huddle(
hs.phase
));
}
+ let generation = hs.begin_huddle_lifetime();
hs.phase = HuddlePhase::Connecting;
hs.parent_channel_id = Some(parent_channel_id.clone());
hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone());
- }
+ generation
+ };
// Seed participant list with own pubkey as a fallback until relay responds.
let own_pubkey = state
@@ -360,12 +397,20 @@ pub async fn join_huddle(
.map(|k| k.public_key().to_hex())
.unwrap_or_default();
- {
+ let committed = {
let mut hs = state.huddle()?;
- hs.phase = HuddlePhase::Connected;
- if !own_pubkey.is_empty() {
- hs.participants = vec![own_pubkey];
+ if !hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Connecting) {
+ false
+ } else {
+ hs.phase = HuddlePhase::Connected;
+ if !own_pubkey.is_empty() {
+ hs.participants = vec![own_pubkey];
+ }
+ true
}
+ };
+ if !committed {
+ return Err("huddle join was superseded".to_owned());
}
// Notify frontend of state change.
@@ -373,15 +418,25 @@ pub async fn join_huddle(
// Hydrate members, download models, start pipelines (incl. audio relay).
// Audio relay failure is fatal — no point in a huddle without audio.
- if let Err(e) = post_connect_setup(&state, &ephemeral_channel_id).await {
- // Rollback: audio relay failed after state was committed.
- // Reset state to Idle so the user can retry. The ephemeral channel
- // has a TTL and will expire — no manual archive needed for joiners.
- if let Ok(mut hs) = state.huddle_state.lock() {
- hs.reset_preserving_generation();
+ match post_connect_setup(&state, &ephemeral_channel_id, huddle_generation).await {
+ Ok(PostConnectOutcome::Ready) => {}
+ Ok(PostConnectOutcome::Stale) => {
+ return Err("huddle join was superseded".to_owned());
+ }
+ Err(e) => {
+ // Reset only the huddle lifetime that failed.
+ let mut did_reset = false;
+ if let Ok(mut hs) = state.huddle_state.lock() {
+ if hs.is_current_huddle(&ephemeral_channel_id, huddle_generation) {
+ hs.reset_preserving_generation();
+ did_reset = true;
+ }
+ }
+ if did_reset {
+ state.emit_huddle_state_changed();
+ }
+ return Err(e);
}
- state.emit_huddle_state_changed();
- return Err(e);
}
Ok(HuddleJoinInfo {
@@ -675,123 +730,6 @@ pub fn push_audio_pcm(
}
}
-/// Hot-start: check if voice models just finished downloading during an active
-/// huddle and start the corresponding pipelines.
-///
-/// Called by the frontend on a timer or after model status changes. No-op if
-/// the huddle is not active or pipelines are already running.
-#[tauri::command]
-pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), String> {
- let (is_active, ephemeral_channel_id) = {
- let hs = state.huddle()?;
- (
- matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active),
- hs.ephemeral_channel_id.clone(),
- )
- };
-
- if !is_active {
- return Ok(());
- }
-
- // Detect dead pipelines: if the worker thread has exited (init failure or crash),
- // clear the pipeline handle so hot-start can retry on the next cycle.
- {
- let mut hs = state.huddle()?;
- if let Some(ref p) = hs.stt_pipeline {
- if p.is_finished() {
- hs.stt_pipeline = None;
- }
- }
- if let Some(ref p) = hs.tts_pipeline {
- if p.is_finished() {
- hs.tts_pipeline = None;
- }
- }
- }
- // Re-read after potential cleanup.
- let (has_stt, has_tts, transcription_enabled) = {
- let hs = state.huddle()?;
- (
- hs.stt_pipeline.is_some(),
- hs.tts_pipeline.is_some(),
- hs.transcription_enabled,
- )
- };
-
- // Check if models just became ready (one-shot flags).
- let stt_ready = models::global_model_manager()
- .map(|m| m.take_stt_ready())
- .unwrap_or(false);
- let tts_ready = models::global_model_manager()
- .map(|m| m.take_tts_ready())
- .unwrap_or(false);
-
- // Start TTS first (so STT can capture tts_cancel).
- if !has_tts && (tts_ready || models::is_tts_ready()) {
- if let Err(e) = maybe_start_tts_pipeline(&state).await {
- eprintln!("buzz-desktop: TTS hotstart failed: {e}");
- }
- }
-
- if transcription_enabled && !has_stt && (stt_ready || models::is_stt_ready()) {
- if let Some(eph_id) = &ephemeral_channel_id {
- if let Err(e) = maybe_start_stt_pipeline(&state, eph_id).await {
- eprintln!("buzz-desktop: STT hotstart failed: {e}");
- }
- }
- }
-
- // Periodically refresh agent_pubkeys from relay membership.
- // This catches mid-huddle agent additions/removals by other participants,
- // keeping STT p-tags authoritative throughout the session.
- // Throttled to every 15 s (not on every 5 s hotstart poll).
- //
- // NOTE: The frontend ALSO polls agent membership independently (every 10 s
- // via get_huddle_agent_pubkeys). This is intentional — the two polls have
- // different failure semantics:
- // - Rust (here): preserves stale list on failure (STT p-tags should not
- // disappear on a transient network blip).
- // - React (HuddleContext.tsx): clears list on failure (TTS authorization
- // must fail-closed — never speak from a stale agent list).
- //
- // On Ok: always replace (even with empty — agents may have been removed).
- // On Err: preserve the existing list (transient failure shouldn't zero it).
- if let Some(eph_id) = &ephemeral_channel_id {
- let should_refresh = {
- let hs = state.huddle()?;
- match hs.last_agent_refresh {
- None => true,
- Some(t) => t.elapsed() >= std::time::Duration::from_secs(15),
- }
- };
- if should_refresh {
- // Fetch agents (for STT p-tags) and all members (for participant list).
- // Sequential — tokio::join! requires the `macros` feature.
- // Only update the throttle timestamp when at least one fetch succeeds,
- // so transient failures retry immediately on the next poll cycle.
- // Fetch both lists before acquiring the lock — no lock held across await.
- let fresh_agents = fetch_channel_members(eph_id, Some("bot"), &state)
- .await
- .ok();
- let fresh_members = fetch_channel_members(eph_id, None, &state).await.ok();
-
- if fresh_agents.is_some() || fresh_members.is_some() {
- let mut hs = state.huddle()?;
- if let Some(agents) = fresh_agents {
- *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents;
- }
- if let Some(members) = fresh_members {
- hs.participants = members;
- }
- hs.last_agent_refresh = Some(std::time::Instant::now());
- }
- }
- }
-
- Ok(())
-}
-
/// Trigger a background download of voice models (Parakeet STT + Pocket TTS).
///
/// Returns immediately — downloads run in tokio background tasks.
@@ -817,91 +755,90 @@ pub fn get_model_status(_state: State<'_, AppState>) -> Result) -> Result<(), String> {
- let old_pipeline = {
- let mut hs = state.huddle()?;
- hs.tts_enabled = enabled;
- if !enabled {
- hs.tts_pipeline.take() // Take out of lock.
- } else {
- None
- }
- };
- // Shut down outside the lock — thread join happens here.
- if let Some(ref pipeline) = old_pipeline {
- pipeline.shutdown();
- }
- drop(old_pipeline);
-
- if enabled {
- // Re-start TTS pipeline if models are available and huddle is active.
- let phase = {
- let hs = state.huddle()?;
- hs.phase.clone()
- };
- if matches!(phase, HuddlePhase::Connected | HuddlePhase::Active) {
- if let Err(e) = maybe_start_tts_pipeline(&state).await {
- eprintln!("buzz-desktop: TTS pipeline restart failed: {e}");
- }
- }
- }
-
- Ok(())
-}
-
/// Speak an agent message via TTS.
///
-/// Maximum text length accepted for TTS synthesis.
-/// ~2000 chars ≈ 1–2 minutes of speech. Longer messages are truncated.
-const MAX_TTS_TEXT_LEN: usize = 2000;
-
-/// Called by the WebView when it receives an incoming agent kind:9 message.
+/// Called by the WebView when it receives an eligible live agent message.
/// Lazily starts the TTS pipeline if models are ready but the pipeline hasn't
/// been created yet (e.g. models finished downloading after huddle started).
///
-/// No-op if TTS is disabled or models aren't ready.
+/// Disabled is the only intentional no-op. Enabled-but-unavailable speech
+/// returns an error so the caller cannot mistake a dropped message for success.
#[tauri::command]
-pub async fn speak_agent_message(text: String, state: State<'_, AppState>) -> Result<(), String> {
+pub async fn speak_agent_message(
+ text: String,
+ route_id: u64,
+ state: State<'_, AppState>,
+) -> Result<(), String> {
+ eprintln!("buzz-desktop: tts stage=invoke status=started route_id={route_id}");
// Truncate oversized messages — agents shouldn't monologue in a voice huddle.
// Use char count (not byte length) to avoid panicking on multi-byte UTF-8.
- let text = if text.chars().count() > MAX_TTS_TEXT_LEN {
- let mut truncated: String = text.chars().take(MAX_TTS_TEXT_LEN).collect();
- truncated.push_str("... message truncated.");
- truncated
- } else {
- text
- };
+ let text = normalize_agent_tts_text(text);
let needs_pipeline = {
- let hs = state.huddle()?;
- hs.tts_enabled
- && hs.tts_pipeline.is_none()
- && matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active)
+ let mut hs = state.huddle()?;
+ if hs
+ .tts_pipeline
+ .as_ref()
+ .is_some_and(|pipeline| pipeline.is_finished())
+ {
+ hs.tts_pipeline = None;
+ }
+ match classify_agent_tts_runtime(hs.tts_enabled, &hs.phase, hs.tts_pipeline.is_some()) {
+ AgentTtsRuntimeGate::Disabled => {
+ eprintln!(
+ "buzz-desktop: tts stage=invoke status=no_op reason=disabled route_id={route_id}"
+ );
+ return Ok(());
+ }
+ AgentTtsRuntimeGate::Inactive => {
+ eprintln!(
+ "buzz-desktop: tts stage=invoke status=failed reason=inactive_huddle route_id={route_id}"
+ );
+ return Err(
+ "Agent text to speech is unavailable outside an active huddle".to_string(),
+ );
+ }
+ AgentTtsRuntimeGate::NeedsPipeline => true,
+ AgentTtsRuntimeGate::Ready => false,
+ }
};
// Lazy-start: models may have finished downloading after the huddle began.
if needs_pipeline {
- if let Err(e) = maybe_start_tts_pipeline(&state).await {
- eprintln!("buzz-desktop: TTS lazy-start failed: {e}");
- }
+ maybe_start_tts_pipeline(&state).await.inspect_err(|_| {
+ eprintln!(
+ "buzz-desktop: tts stage=invoke status=failed reason=startup_failed route_id={route_id}"
+ );
+ })?;
+ await_inflight_tts_start(&state).await.inspect_err(|_| {
+ eprintln!(
+ "buzz-desktop: tts stage=invoke status=failed reason=startup_timeout route_id={route_id}"
+ );
+ })?;
}
- let hs = state.huddle()?;
- if hs.tts_enabled {
- if let Some(ref pipeline) = hs.tts_pipeline {
- pipeline.speak(text)?;
- }
- }
- Ok(())
+ let sender = {
+ let hs = state.huddle()?;
+ hs.tts_pipeline
+ .as_ref()
+ .map(|pipeline| pipeline.text_sender())
+ };
+ let Some(sender) = sender else {
+ eprintln!(
+ "buzz-desktop: tts stage=invoke status=failed reason=unavailable route_id={route_id}"
+ );
+ return Err("Agent text to speech is enabled but its audio pipeline is unavailable".into());
+ };
+ enqueue_agent_tts_text(route_id, text, move |route_id, text| {
+ sender
+ .send(route_id, text)
+ .map_err(|error| format!("TTS queue closed while waiting to enqueue: {error}"))
+ })
+ .await
+ .inspect(|_| eprintln!("buzz-desktop: tts stage=queue status=accepted route_id={route_id}"))
+ .inspect_err(|_| {
+ eprintln!("buzz-desktop: tts stage=queue status=failed reason=closed route_id={route_id}")
+ })
}
/// Add an agent to the active huddle.
@@ -924,7 +861,7 @@ pub async fn add_agent_to_huddle(
) -> Result {
validate_pubkey_hex(&agent_pubkey)?;
- let (eph_id, parent_id) = {
+ let (eph_id, parent_id, huddle_generation) = {
let hs = state.huddle()?;
if !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) {
return Err("no active huddle".to_string());
@@ -948,7 +885,7 @@ pub async fn add_agent_to_huddle(
.clone()
.ok_or("no ephemeral channel")?;
let parent = hs.parent_channel_id.clone().ok_or("no parent channel")?;
- (eph, parent)
+ (eph, parent, hs.huddle_generation)
};
let eph_uuid = Uuid::parse_str(&eph_id).map_err(|e| e.to_string())?;
@@ -957,29 +894,30 @@ pub async fn add_agent_to_huddle(
// Returns Err only if the ephemeral add fails — parent failure is in the result.
let result = agents::add_agent_to_huddle(eph_uuid, parent_uuid, &agent_pubkey, &state).await?;
- // Ephemeral add succeeded — safe to register for p-tagging.
- // Clone the Arc first so we can drop the outer HuddleState lock before
- // acquiring the inner pubkeys lock (avoids the E0597 borrow-checker error).
- {
- let agent_pubkeys_arc = {
- let hs = state.huddle()?;
- Arc::clone(&hs.agent_pubkeys)
- };
- let mut pubkeys = agent_pubkeys_arc.lock().unwrap_or_else(|e| e.into_inner());
+ // Ephemeral add succeeded — register it only if this is still the huddle
+ // that initiated the relay operation.
+ let transcription_auto_enabled = {
+ let mut hs = state.huddle()?;
+ if !hs.is_current_huddle(&eph_id, huddle_generation) {
+ return Ok(result);
+ }
+ let mut pubkeys = hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner());
if !pubkeys.contains(&agent_pubkey) {
pubkeys.push(agent_pubkey.clone());
}
- }
+ drop(pubkeys);
+ if !hs.participants.contains(&agent_pubkey) {
+ hs.participants.push(agent_pubkey.clone());
+ }
+ hs.maybe_auto_enable_transcription_for_agents()
+ };
// No guidelines re-post needed — the agent sees the original kind:48106
// guidelines via EOSE replay when it subscribes to the ephemeral channel.
-
- // Also add the agent to the visible participants list.
- {
- let mut hs = state.huddle()?;
- if !hs.participants.contains(&agent_pubkey) {
- hs.participants.push(agent_pubkey);
- }
+ if transcription_auto_enabled {
+ start_auto_enabled_transcription(&state, &eph_id).await;
+ } else {
+ state.emit_huddle_state_changed();
}
Ok(result)
diff --git a/desktop/src-tauri/src/huddle/models.rs b/desktop/src-tauri/src/huddle/models.rs
index 169ddf66c0..f9f7065769 100644
--- a/desktop/src-tauri/src/huddle/models.rs
+++ b/desktop/src-tauri/src/huddle/models.rs
@@ -24,6 +24,14 @@ use std::sync::{Arc, Mutex, OnceLock};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
+use super::pocket::{
+ april_model_info, PocketModelArtifact, APRIL_BUNDLE_ID, APRIL_MODEL_ID, APRIL_MODEL_REVISION,
+};
+use super::tts_voice_registry::POCKET_VOICES;
+
+#[path = "models_voice_upgrade.rs"]
+mod voice_upgrade;
+
// ── Integrity verification ────────────────────────────────────────────────────
//
// All model artifacts are verified against pinned SHA-256 hashes before
@@ -38,19 +46,15 @@ use sha2::{Digest, Sha256};
/// Computed from a known-good download. Update when upgrading model versions.
const STT_ARCHIVE_SHA256: &str = "17f945007b52ccd8b7200ffc7c5652e9e8e961dfdf479cefcabd06cf5703630b";
-/// HuggingFace base URL for the sherpa-onnx Pocket TTS fp32 repackage.
-///
-/// Pinned to commit 96d1e53ce3311ca6c2c6a35e2062d36b4cec6fa3
-/// (2026-02-10) for reproducible downloads.
-///
-/// fp32 (not int8): a direct same-runtime A/B (k2-fsa/sherpa-onnx#3172)
-/// found the ONNX int8 quantization audibly degraded Pocket TTS output and
-/// that fp32 "significantly improved quality even at 1 step". The runtime
-/// bundle grows from ~189 MB to ~473 MB; encoder, text conditioner, both
-/// JSON tables, and LICENSE are byte-identical between the two repos — only
-/// the three quantized sessions (lm_main, lm_flow, decoder) change.
-const POCKET_HF_BASE: &str =
- "https://huggingface.co/csukuangfj2/sherpa-onnx-pocket-tts-2026-01-26/resolve/96d1e53ce3311ca6c2c6a35e2062d36b4cec6fa3";
+fn pocket_artifact_url(filename: &str) -> String {
+ format!(
+ "https://huggingface.co/{APRIL_MODEL_ID}/resolve/{APRIL_MODEL_REVISION}/onnx/{APRIL_BUNDLE_ID}/{filename}"
+ )
+}
+
+fn pocket_license_url() -> String {
+ format!("https://huggingface.co/{APRIL_MODEL_ID}/resolve/{APRIL_MODEL_REVISION}/onnx/LICENSE")
+}
/// Reference voice WAV: "Mary (f, conversation)" from the Kyutai TTS demo
/// voice set — VCTK speaker p333, ai-coustics-enhanced. Pinned to
@@ -64,20 +68,19 @@ const POCKET_HF_BASE: &str =
const POCKET_REFERENCE_WAV_URL: &str =
"https://huggingface.co/kyutai/tts-voices/resolve/323332d33f997de8394f24a193e1a76df720e01a/vctk/p333_023_enhanced.wav";
-/// SHA-256 hashes for individual Pocket TTS model files.
-/// Computed from known-good pinned downloads. Update when upgrading model versions.
-#[rustfmt::skip]
-const TTS_FILE_HASHES: &[(&str, &str)] = &[
- ("decoder.onnx", "f267880fde6c58b17b0a8f3647eaf8dcfad321f833f32d583ebc2fb2d1a15f10"),
- ("encoder.onnx", "e8f2f6d301ffb96e398b138a7dc6d3038622d236044636b73d920bab85890260"),
- ("lm_flow.onnx", "79c013a554a54e63319c33c0cc8830cbbedc9b7e448ae7e26f7923ae11f9873e"),
- ("lm_main.onnx", "255d1a9263c5abdf36034abfc19c11d21cc5f40f0f87d8361288e972cbd5c578"),
- ("text_conditioner.onnx", "0b84e837d7bfaf2c896627b03e3f080320309f37f4fc7df7698c644f7ba5e6b1"),
- ("vocab.json", "6fb646346cf931016f70c4921aab0900ce7a304b893cb02135c74e294abfea01"),
- ("token_scores.json", "5be2f278caf9b9800741f0fd82bff677f4943ec764c356f907213434b622d958"),
- ("LICENSE", "fe7b4ce83b8381cc5b216bbb4af73c570688d1b819c73bbaed8ca401f4677cd6"),
- ("reference_sample.wav", "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f"),
-];
+const TTS_LICENSE_ARTIFACT: PocketModelArtifact = PocketModelArtifact {
+ filename: "LICENSE",
+ sha256: "fe7b4ce83b8381cc5b216bbb4af73c570688d1b819c73bbaed8ca401f4677cd6",
+ size_bytes: 18_655,
+ quantized: false,
+};
+
+const TTS_REFERENCE_ARTIFACT: PocketModelArtifact = PocketModelArtifact {
+ filename: "reference_sample.wav",
+ sha256: "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f",
+ size_bytes: 639_084,
+ quantized: false,
+};
// ── Model versioning ──────────────────────────────────────────────────────────
//
@@ -92,15 +95,8 @@ const TTS_FILE_HASHES: &[(&str, &str)] = &[
/// honest (each version tag identifies one specific set of model bytes).
const STT_MODEL_VERSION: &str = "2";
-/// Model manifest version for Pocket TTS. Increment when upgrading model files.
-/// Bumped "1" → "2" when the bundled reference voice changed from KevinAHM's
-/// anonymous 16 kHz sample to Mary (VCTK p333, 32 kHz, ai-coustics-enhanced)
-/// from kyutai/tts-voices. The hash mismatch on `reference_sample.wav` would
-/// fail readiness on its own, but the manifest bump makes the re-download
-/// reason explicit and skips the failing-then-re-fetching transient state.
-/// Bumped "2" → "3" for the int8 → fp32 model swap (see `POCKET_HF_BASE`):
-/// existing int8 installs must re-download the suffixless fp32 sessions.
-const TTS_MODEL_VERSION: &str = "3";
+/// Identifies the April INT8 asset set plus the official VCTK presets.
+const TTS_MODEL_VERSION: &str = "5";
/// Filename for the version manifest written alongside model files.
const MANIFEST_FILENAME: &str = ".buzz-model-manifest";
@@ -110,9 +106,9 @@ const MANIFEST_FILENAME: &str = ".buzz-model-manifest";
/// Maximum expected STT archive size (200 MB — actual is ~100 MB).
const MAX_STT_DOWNLOAD_BYTES: u64 = 200 * 1024 * 1024;
-/// Maximum expected Pocket TTS file size (400 MB per file — largest is
-/// `lm_main.onnx` at ~303 MB fp32).
-const MAX_TTS_FILE_BYTES: u64 = 400 * 1024 * 1024;
+/// Maximum expected Pocket TTS file size. The largest pinned INT8 artifact is
+/// `flow_lm_main_int8.onnx` at 76,341,079 bytes.
+const MAX_TTS_FILE_BYTES: u64 = 100 * 1024 * 1024;
/// NVIDIA Parakeet TDT-CTC 110M (English, int8) — packaged for sherpa-onnx by
/// k2-fsa. Single ONNX file (CTC head) + tokens.txt. Avg WER ~7.5% across
@@ -168,50 +164,29 @@ const TTS_MODEL_DIR_NAME: &str = "pocket-tts";
/// Attribution sidecar written next to the Pocket TTS model files.
const TTS_LICENSE_FILE_NAME: &str = "MODEL_LICENSE.txt";
-/// CC-BY-4.0 §3(a)(1) attribution block for Pocket TTS, its ONNX packaging,
-/// and the bundled reference voice WAV.
-const TTS_LICENSE_TEXT: &str = "\
-Pocket TTS
-© Kyutai.
-
-Licensed under the Creative Commons Attribution 4.0 International License
-(CC-BY-4.0). License text: https://creativecommons.org/licenses/by/4.0/
-
-Original model by Kyutai: https://huggingface.co/kyutai/pocket-tts
-Paper: Charles, Roebel, et al., Pocket TTS (arXiv:2509.06926).
-Mimi neural codec by Kyutai is bundled as part of the model.
-
-ONNX export by KevinAHM: https://huggingface.co/KevinAHM/pocket-tts-onnx
-Sherpa-onnx repackage by csukuangfj / k2-fsa:
-https://huggingface.co/csukuangfj2/sherpa-onnx-pocket-tts-2026-01-26
-
-Bundled reference voice (reference_sample.wav):
-\"Mary (f, conversation)\" preset from the Kyutai TTS demo voice catalogue
-(https://kyutai.org/tts), distributed via
-https://huggingface.co/kyutai/tts-voices as `vctk/p333_023_enhanced.wav`.
-Original recording from the Voice Cloning Toolkit (VCTK) corpus, speaker p333:
-https://datashare.ed.ac.uk/handle/10283/3443 (CC-BY-4.0).
-Recording enhancement (denoise/dereverb) by ai-coustics:
-https://ai-coustics.com/
-
-Buzz ships all ONNX/model artifacts and the reference voice WAV unmodified,
-renamed only by placement in the local model directory.
-
-Provided \"AS IS\", without warranty of any kind, express or implied. See the
-license text for full warranty disclaimer.
-";
-
/// All files that must be present for Pocket TTS to be considered ready.
const TTS_EXPECTED_FILES: &[&str] = &[
- "decoder.onnx",
- "encoder.onnx",
- "lm_flow.onnx",
- "lm_main.onnx",
+ "bundle.json",
+ "bos_before_voice.npy",
+ "flow_lm_main_int8.onnx",
+ "flow_lm_flow_int8.onnx",
+ "mimi_decoder_int8.onnx",
+ "mimi_encoder.onnx",
"text_conditioner.onnx",
- "vocab.json",
- "token_scores.json",
+ "tokenizer.model",
"LICENSE",
"reference_sample.wav",
+ "anna.wav",
+ "vera.wav",
+ "fantine.wav",
+ "charles.wav",
+ "paul.wav",
+ "eponine.wav",
+ "azelma.wav",
+ "george.wav",
+ "jane.wav",
+ "michael.wav",
+ "eve.wav",
TTS_LICENSE_FILE_NAME,
];
@@ -404,6 +379,7 @@ struct ModelSlot {
dir_name: &'static str, // subdir under ~/.buzz/models/
expected_files: &'static [&'static str], // files required for "ready"
version: &'static str, // manifest version; increment to force re-download
+ expected_size: fn(&str) -> Option,
status: Arc>,
just_ready: Arc, // fires once when download completes
}
@@ -418,11 +394,17 @@ impl ModelSlot {
dir_name,
expected_files,
version,
+ expected_size: |_| None,
status: Arc::new(Mutex::new(ModelStatus::NotDownloaded)),
just_ready: Arc::new(AtomicBool::new(false)),
}
}
+ fn with_expected_sizes(mut self, expected_size: fn(&str) -> Option) -> Self {
+ self.expected_size = expected_size;
+ self
+ }
+
fn model_dir(&self, models_dir: &Path) -> PathBuf {
models_dir.join(self.dir_name)
}
@@ -432,7 +414,17 @@ impl ModelSlot {
std::fs::read_to_string(dir.join(MANIFEST_FILENAME))
.map(|v| v.trim() == self.version)
.unwrap_or(false)
- && self.expected_files.iter().all(|f| dir.join(f).is_file())
+ && self.expected_files.iter().all(|filename| {
+ let path = dir.join(filename);
+ path.is_file()
+ && (self.expected_size)(filename)
+ .map(|expected| {
+ path.metadata()
+ .map(|metadata| metadata.len() == expected)
+ .unwrap_or(false)
+ })
+ .unwrap_or(true)
+ })
}
fn dir_if_ready(&self, models_dir: &Path) -> Option {
@@ -453,6 +445,39 @@ impl ModelSlot {
self.just_ready.swap(false, Ordering::AcqRel)
}
+ /// Recover or clean up the backup left by an interrupted atomic install.
+ fn recover_interrupted_install(&self, models_dir: &Path) {
+ let final_dir = self.model_dir(models_dir);
+ let backup_dir = final_dir.with_extension("old");
+ if !backup_dir.exists() {
+ return;
+ }
+ if self.is_ready(models_dir) {
+ if let Err(error) = std::fs::remove_dir_all(&backup_dir) {
+ eprintln!(
+ "buzz-desktop: could not remove stale {} backup: {error}",
+ self.dir_name
+ );
+ }
+ return;
+ }
+ if final_dir.exists() {
+ if let Err(error) = std::fs::remove_dir_all(&final_dir) {
+ eprintln!(
+ "buzz-desktop: could not remove incomplete {} install: {error}",
+ self.dir_name
+ );
+ return;
+ }
+ }
+ if let Err(error) = std::fs::rename(&backup_dir, &final_dir) {
+ eprintln!(
+ "buzz-desktop: could not restore interrupted {} install: {error}",
+ self.dir_name
+ );
+ }
+ }
+
/// Spawn a background download task if not already ready or downloading.
fn start_download(
&self,
@@ -511,6 +536,9 @@ impl ModelSlot {
));
}
+ std::fs::write(source_dir.join(MANIFEST_FILENAME), self.version)
+ .map_err(|e| format!("write model manifest: {e}"))?;
+
let final_dir = self.model_dir(models_dir);
let backup_dir = final_dir.with_extension("old");
@@ -529,8 +557,6 @@ impl ModelSlot {
return Err(format!("install new model: {e}"));
}
- std::fs::write(final_dir.join(MANIFEST_FILENAME), self.version)
- .map_err(|e| format!("write model manifest: {e}"))?;
let _ = tokio::fs::remove_dir_all(&backup_dir).await;
if let Some(extra) = temp_cleanup {
let _ = tokio::fs::remove_dir_all(extra).await;
@@ -542,6 +568,25 @@ impl ModelSlot {
}
}
+fn tts_expected_size(filename: &str) -> Option {
+ april_model_info()
+ .artifacts
+ .iter()
+ .find(|artifact| artifact.filename == filename)
+ .map(|artifact| artifact.size_bytes)
+ .or_else(|| {
+ [TTS_LICENSE_ARTIFACT, TTS_REFERENCE_ARTIFACT]
+ .iter()
+ .find(|artifact| artifact.filename == filename)
+ .map(|artifact| artifact.size_bytes)
+ })
+}
+
+fn tts_model_slot() -> ModelSlot {
+ ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION)
+ .with_expected_sizes(tts_expected_size)
+}
+
// ── ModelManager ──────────────────────────────────────────────────────────────
/// Manages download and location of STT/TTS model files.
@@ -561,11 +606,13 @@ impl ModelManager {
/// Returns `None` if the home directory cannot be resolved.
pub fn new() -> Option {
let models_dir = dirs::home_dir()?.join(".buzz").join("models");
- Some(Self {
+ let manager = Self {
models_dir,
stt: ModelSlot::new(STT_MODEL_DIR_NAME, STT_EXPECTED_FILES, STT_MODEL_VERSION),
- tts: ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION),
- })
+ tts: tts_model_slot(),
+ };
+ manager.tts.recover_interrupted_install(&manager.models_dir);
+ Some(manager)
}
// ── STT accessors ────────────────────────────────────────────────────────
@@ -638,8 +685,11 @@ impl ModelManager {
}
}
- /// Start a background Pocket TTS download (~189 MB). No-op if already ready or downloading.
+ /// Start a background Pocket TTS download. No-op if already ready or downloading.
pub fn start_tts_download(&self, http_client: reqwest::Client) {
+ if let Err(error) = voice_upgrade::install_vctk_presets_into_v4_model(&self.models_dir) {
+ eprintln!("buzz-desktop: could not upgrade existing Pocket voices in place: {error}");
+ }
let manager = self.clone();
self.tts.start_download(
&self.models_dir,
@@ -754,10 +804,10 @@ impl ModelManager {
/// Download and verify the Pocket TTS model files from HuggingFace.
///
/// Downloads files into `~/.buzz/models/pocket-tts/`:
- /// - five ONNX sessions (Pocket TTS + Mimi codec)
- /// - `vocab.json` / `token_scores.json` for sherpa-onnx text conditioning
+ /// - five ONNX sessions selected by the April INT8 bundle
+ /// - bundle metadata, SentencePiece tokenizer, and learned voice BOS
/// - upstream `LICENSE` plus Buzz's `MODEL_LICENSE.txt` attribution sidecar
- /// - `reference_sample.wav` as the bundled default voice
+ /// - `reference_sample.wav` plus the embedded official VCTK presets
///
/// Files are written to a temp directory first, then moved atomically.
async fn download_tts_model(&self, http_client: reqwest::Client) -> Result<(), String> {
@@ -768,24 +818,18 @@ impl ModelManager {
let temp_dir = self.models_dir.join("pocket-tts.tmp");
fresh_temp_dir(&temp_dir).await?;
- let model_files = [
- "decoder.onnx",
- "encoder.onnx",
- "lm_flow.onnx",
- "lm_main.onnx",
- "text_conditioner.onnx",
- "vocab.json",
- "token_scores.json",
- "LICENSE",
- ];
- let mut downloads: Vec<(String, &'static str)> = model_files
+ let mut downloads: Vec<(String, PocketModelArtifact)> = april_model_info()
+ .artifacts
.iter()
- .map(|filename| (format!("{POCKET_HF_BASE}/{filename}"), *filename))
+ .copied()
+ .map(|artifact| (pocket_artifact_url(artifact.filename), artifact))
.collect();
- downloads.push((POCKET_REFERENCE_WAV_URL.to_string(), "reference_sample.wav"));
+ downloads.push((pocket_license_url(), TTS_LICENSE_ARTIFACT));
+ downloads.push((POCKET_REFERENCE_WAV_URL.to_string(), TTS_REFERENCE_ARTIFACT));
let total_files = downloads.len() as u32;
- for (i, (url, filename)) in downloads.iter().enumerate() {
+ for (i, (url, artifact)) in downloads.iter().enumerate() {
+ let filename = artifact.filename;
eprintln!("buzz-desktop: downloading Pocket TTS {filename} from {url}");
let response = fetch_url(&http_client, url, filename)
@@ -822,16 +866,19 @@ impl ModelManager {
})?;
eprintln!("buzz-desktop: downloaded {bytes} bytes ({filename}), wrote to disk");
- let expected = TTS_FILE_HASHES
- .iter()
- .find(|(n, _)| *n == *filename)
- .map(|(_, hash)| *hash)
- .ok_or_else(|| format!("missing expected hash for Pocket TTS file: {filename}"))?;
+ if bytes != artifact.size_bytes {
+ let _ = tokio::fs::remove_dir_all(&temp_dir).await;
+ return Err(format!(
+ "Pocket TTS {filename} size check failed: expected {} bytes, got {bytes}",
+ artifact.size_bytes
+ ));
+ }
let actual = sha256_file(&dest).await?;
- if actual != expected {
+ if actual != artifact.sha256 {
let _ = tokio::fs::remove_dir_all(&temp_dir).await;
return Err(format!(
- "Pocket TTS {filename} integrity check failed: expected {expected}, got {actual}"
+ "Pocket TTS {filename} integrity check failed: expected {}, got {actual}",
+ artifact.sha256
));
}
@@ -842,9 +889,20 @@ impl ModelManager {
});
}
- tokio::fs::write(temp_dir.join(TTS_LICENSE_FILE_NAME), TTS_LICENSE_TEXT)
- .await
- .map_err(|e| format!("write TTS model license sidecar: {e}"))?;
+ tokio::fs::write(
+ temp_dir.join(TTS_LICENSE_FILE_NAME),
+ voice_upgrade::TTS_LICENSE_TEXT,
+ )
+ .await
+ .map_err(|e| format!("write TTS model license sidecar: {e}"))?;
+ for voice in POCKET_VOICES {
+ let Some(bytes) = voice.bytes else {
+ continue;
+ };
+ tokio::fs::write(temp_dir.join(voice.reference_file), bytes)
+ .await
+ .map_err(|e| format!("install bundled {} voice: {e}", voice.display_name))?;
+ }
self.tts.set_status(ModelStatus::Downloading {
progress_percent: 90,
@@ -931,24 +989,5 @@ pub fn is_tts_ready() -> bool {
}
#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn tts_readiness_requires_license_sidecar() {
- let temp = tempfile::tempdir().expect("tempdir");
- let slot = ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION);
- let model_dir = temp.path().join(TTS_MODEL_DIR_NAME);
- std::fs::create_dir_all(&model_dir).expect("create model dir");
-
- for file in TTS_EXPECTED_FILES {
- std::fs::write(model_dir.join(file), b"test").expect("write expected file");
- }
- std::fs::write(model_dir.join(MANIFEST_FILENAME), TTS_MODEL_VERSION).expect("manifest");
-
- assert!(slot.is_ready(temp.path()));
-
- std::fs::remove_file(model_dir.join(TTS_LICENSE_FILE_NAME)).expect("remove sidecar");
- assert!(!slot.is_ready(temp.path()));
- }
-}
+#[path = "models_tests.rs"]
+mod tests;
diff --git a/desktop/src-tauri/src/huddle/models_tests.rs b/desktop/src-tauri/src/huddle/models_tests.rs
new file mode 100644
index 0000000000..699ffbe459
--- /dev/null
+++ b/desktop/src-tauri/src/huddle/models_tests.rs
@@ -0,0 +1,146 @@
+use super::*;
+
+fn create_ready_model_dir(root: &Path) -> PathBuf {
+ let model_dir = root.join(TTS_MODEL_DIR_NAME);
+ std::fs::create_dir_all(&model_dir).expect("create model dir");
+ for file in TTS_EXPECTED_FILES {
+ let path = model_dir.join(file);
+ let handle = std::fs::File::create(path).expect("create expected file");
+ if let Some(size) = tts_expected_size(file) {
+ handle.set_len(size).expect("size expected file");
+ } else {
+ std::fs::write(model_dir.join(file), b"test").expect("write expected file");
+ }
+ }
+ std::fs::write(model_dir.join(MANIFEST_FILENAME), TTS_MODEL_VERSION).expect("manifest");
+ model_dir
+}
+
+#[test]
+fn expected_files_match_april_int8_metadata() {
+ let mut expected = april_model_info()
+ .artifacts
+ .iter()
+ .map(|artifact| artifact.filename)
+ .chain([TTS_LICENSE_ARTIFACT.filename, TTS_LICENSE_FILE_NAME])
+ .chain(POCKET_VOICES.iter().map(|voice| voice.reference_file))
+ .collect::>();
+ expected.sort_unstable();
+ let mut actual = TTS_EXPECTED_FILES.to_vec();
+ actual.sort_unstable();
+
+ assert_eq!(actual, expected);
+ assert!(!actual.contains(&"flow_lm_main.onnx"));
+ assert!(!actual.contains(&"flow_lm_flow.onnx"));
+ assert!(!actual.contains(&"mimi_decoder.onnx"));
+ assert!(!actual.contains(&"marius.wav"));
+}
+
+#[test]
+fn tts_readiness_requires_license_sidecar() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let slot = tts_model_slot();
+ let model_dir = create_ready_model_dir(temp.path());
+
+ assert!(slot.is_ready(temp.path()));
+
+ std::fs::remove_file(model_dir.join(TTS_LICENSE_FILE_NAME)).expect("remove sidecar");
+ assert!(!slot.is_ready(temp.path()));
+}
+
+#[test]
+fn tts_readiness_rejects_truncated_pinned_artifact() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let slot = tts_model_slot();
+ let model_dir = create_ready_model_dir(temp.path());
+ let artifact = april_model_info().artifacts[0];
+
+ std::fs::OpenOptions::new()
+ .write(true)
+ .open(model_dir.join(artifact.filename))
+ .expect("open artifact")
+ .set_len(artifact.size_bytes - 1)
+ .expect("truncate artifact");
+
+ assert!(!slot.is_ready(temp.path()));
+}
+
+#[test]
+fn january_cache_is_not_ready_for_april_int8() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let slot = tts_model_slot();
+ let model_dir = temp.path().join(TTS_MODEL_DIR_NAME);
+ std::fs::create_dir_all(&model_dir).expect("create model dir");
+ for file in [
+ "decoder.onnx",
+ "encoder.onnx",
+ "lm_flow.onnx",
+ "lm_main.onnx",
+ "text_conditioner.onnx",
+ "vocab.json",
+ "token_scores.json",
+ "LICENSE",
+ "reference_sample.wav",
+ TTS_LICENSE_FILE_NAME,
+ ] {
+ std::fs::write(model_dir.join(file), b"january").expect("write January file");
+ }
+ std::fs::write(model_dir.join(MANIFEST_FILENAME), "3").expect("manifest");
+
+ assert!(!slot.is_ready(temp.path()));
+}
+
+#[test]
+fn interrupted_install_restores_backup_when_destination_is_missing() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let slot = tts_model_slot();
+ let backup_dir = temp.path().join("pocket-tts.old");
+ std::fs::create_dir_all(&backup_dir).expect("create backup");
+ std::fs::write(backup_dir.join("sentinel"), b"previous").expect("write sentinel");
+
+ slot.recover_interrupted_install(temp.path());
+
+ assert_eq!(
+ std::fs::read(temp.path().join(TTS_MODEL_DIR_NAME).join("sentinel"))
+ .expect("restored sentinel"),
+ b"previous"
+ );
+ assert!(!backup_dir.exists());
+}
+
+#[test]
+fn interrupted_install_replaces_incomplete_destination_with_backup() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let slot = tts_model_slot();
+ let model_dir = temp.path().join(TTS_MODEL_DIR_NAME);
+ let backup_dir = temp.path().join("pocket-tts.old");
+ std::fs::create_dir_all(&model_dir).expect("create incomplete destination");
+ std::fs::write(model_dir.join("incomplete"), b"april").expect("write incomplete file");
+ std::fs::create_dir_all(&backup_dir).expect("create backup");
+ std::fs::write(backup_dir.join("sentinel"), b"previous").expect("write sentinel");
+
+ slot.recover_interrupted_install(temp.path());
+
+ assert_eq!(
+ std::fs::read(model_dir.join("sentinel")).expect("restored sentinel"),
+ b"previous"
+ );
+ assert!(!model_dir.join("incomplete").exists());
+ assert!(!backup_dir.exists());
+}
+
+#[test]
+fn ready_destination_removes_stale_backup() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let slot = tts_model_slot();
+ let model_dir = create_ready_model_dir(temp.path());
+ let backup_dir = temp.path().join("pocket-tts.old");
+ std::fs::create_dir_all(&backup_dir).expect("create backup");
+ std::fs::write(backup_dir.join("sentinel"), b"previous").expect("write sentinel");
+
+ slot.recover_interrupted_install(temp.path());
+
+ assert!(slot.is_ready(temp.path()));
+ assert!(model_dir.exists());
+ assert!(!backup_dir.exists());
+}
diff --git a/desktop/src-tauri/src/huddle/models_voice_upgrade.rs b/desktop/src-tauri/src/huddle/models_voice_upgrade.rs
new file mode 100644
index 0000000000..5233e02615
--- /dev/null
+++ b/desktop/src-tauri/src/huddle/models_voice_upgrade.rs
@@ -0,0 +1,128 @@
+use super::*;
+use crate::huddle::tts_voice_registry::POCKET_VOICES;
+
+const PRESET_VOICE_TTS_MODEL_VERSION: &str = "4";
+
+/// Attribution written beside every installed Pocket model and voice asset.
+pub(super) const TTS_LICENSE_TEXT: &str = "\
+Pocket TTS
+© Kyutai.
+
+Licensed under the Creative Commons Attribution 4.0 International License
+(CC-BY-4.0). License text: https://creativecommons.org/licenses/by/4.0/
+
+Original model by Kyutai: https://huggingface.co/kyutai/pocket-tts
+Paper: Charles, Roebel, et al., Pocket TTS (arXiv:2509.06926).
+Mimi neural codec by Kyutai is bundled as part of the model.
+
+April 2026 ONNX export by KevinAHM:
+https://huggingface.co/KevinAHM/pocket-tts-onnx
+Pinned revision: 58a6d00cf13d239b6748cb0769f35c580a8f606c
+
+Bundled English VCTK presets: Anna (p228), Vera (p229), Fantine (p244),
+Charles (p254), Paul (p259), Eponine (p262), Azelma (p303), George (p315),
+Mary (p333), Jane (p339), Michael (p360), and Eve (p361). These exact,
+ai-coustics-enhanced WAVs come from Kyutai's tts-voices repository at revision
+323332d33f997de8394f24a193e1a76df720e01a.
+Source: https://huggingface.co/kyutai/tts-voices/tree/323332d33f997de8394f24a193e1a76df720e01a/vctk
+Original recordings: Voice Cloning Toolkit (VCTK) corpus,
+https://datashare.ed.ac.uk/handle/10283/3443 (CC-BY-4.0).
+Enhancement (denoise/dereverb): ai-coustics, https://ai-coustics.com/
+
+Buzz ships the ONNX/model artifacts and voice WAVs unmodified, renamed only
+by placement in the local model directory.
+
+Provided \"AS IS\", without warranty of any kind, express or implied. See the
+license text for full warranty disclaimer.
+";
+
+fn is_embedded_voice_file(filename: &str) -> bool {
+ POCKET_VOICES
+ .iter()
+ .any(|voice| voice.bytes.is_some() && voice.reference_file == filename)
+}
+
+/// Add the official VCTK presets to an otherwise-ready v4 install.
+///
+/// Model artifacts and Mary already exist in v4. The manifest is written last,
+/// so interruption leaves v4 intact and the next launch retries.
+pub(super) fn install_vctk_presets_into_v4_model(models_dir: &Path) -> Result<(), String> {
+ let model_dir = models_dir.join(TTS_MODEL_DIR_NAME);
+ let manifest_path = model_dir.join(MANIFEST_FILENAME);
+ let version = match std::fs::read_to_string(&manifest_path) {
+ Ok(version) => version,
+ Err(_) => return Ok(()),
+ };
+ if version.trim() != PRESET_VOICE_TTS_MODEL_VERSION {
+ return Ok(());
+ }
+ if !TTS_EXPECTED_FILES
+ .iter()
+ .filter(|filename| !is_embedded_voice_file(filename))
+ .all(|filename| model_dir.join(filename).is_file())
+ {
+ return Ok(());
+ }
+
+ for voice in POCKET_VOICES {
+ let Some(bytes) = voice.bytes else {
+ continue;
+ };
+ std::fs::write(model_dir.join(voice.reference_file), bytes)
+ .map_err(|error| format!("write bundled {} voice: {error}", voice.display_name))?;
+ }
+ let retired_marius = model_dir.join("marius.wav");
+ if retired_marius.is_file() {
+ std::fs::remove_file(retired_marius)
+ .map_err(|error| format!("remove retired Marius voice: {error}"))?;
+ }
+ std::fs::write(model_dir.join(TTS_LICENSE_FILE_NAME), TTS_LICENSE_TEXT)
+ .map_err(|error| format!("update Pocket voice notice: {error}"))?;
+ std::fs::write(manifest_path, TTS_MODEL_VERSION)
+ .map_err(|error| format!("update Pocket model manifest: {error}"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn v4_install_adds_presets_without_redownloading_models() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let model_dir = temp.path().join(TTS_MODEL_DIR_NAME);
+ std::fs::create_dir_all(&model_dir).expect("create model dir");
+ for file in TTS_EXPECTED_FILES
+ .iter()
+ .filter(|filename| !is_embedded_voice_file(filename))
+ {
+ std::fs::write(model_dir.join(file), b"existing").expect("write prior file");
+ }
+ std::fs::write(
+ model_dir.join(MANIFEST_FILENAME),
+ PRESET_VOICE_TTS_MODEL_VERSION,
+ )
+ .expect("write prior manifest");
+ std::fs::write(model_dir.join("marius.wav"), b"retired").expect("write retired voice");
+
+ install_vctk_presets_into_v4_model(temp.path()).expect("in-place upgrade");
+
+ for voice in POCKET_VOICES {
+ if let Some(bytes) = voice.bytes {
+ assert_eq!(
+ std::fs::read(model_dir.join(voice.reference_file))
+ .expect("bundled voice installed"),
+ bytes
+ );
+ }
+ }
+ assert_eq!(
+ std::fs::read_to_string(model_dir.join(MANIFEST_FILENAME)).expect("updated manifest"),
+ TTS_MODEL_VERSION
+ );
+ assert!(!model_dir.join("marius.wav").exists());
+ assert!(
+ ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION)
+ .is_ready(temp.path())
+ );
+ }
+}
diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs
index 6a4cf26201..fba5464a69 100644
--- a/desktop/src-tauri/src/huddle/pipeline.rs
+++ b/desktop/src-tauri/src/huddle/pipeline.rs
@@ -3,12 +3,16 @@
//! Handles starting, hot-starting, and spawning transcription tasks for
//! the voice pipelines. Extracted from mod.rs to keep the command layer thin.
-use std::sync::{
- atomic::{AtomicU64, Ordering},
- Arc, Mutex,
+use std::{
+ sync::{
+ atomic::{AtomicU64, Ordering},
+ Arc, Mutex,
+ },
+ time::Duration,
};
use nostr::JsonUtil;
+use tauri::State;
use uuid::Uuid;
use crate::app_state::AppState;
@@ -16,59 +20,228 @@ use crate::events;
use super::models;
use super::relay_api::{self, fetch_channel_members, parse_channel_uuid};
-use super::state::{HuddlePhase, VoiceInputMode};
+use super::state::{HuddlePhase, HuddleState, VoiceInputMode};
use super::stt;
use super::tts;
+pub(crate) enum PostConnectOutcome {
+ Ready,
+ Stale,
+}
+
+/// Hot-start: check if voice models just finished downloading during an active
+/// huddle and start the corresponding pipelines.
+///
+/// Called by the frontend on a timer or after model status changes. No-op if
+/// the huddle is not active or pipelines are already running.
+#[tauri::command]
+pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), String> {
+ let (is_active, ephemeral_channel_id, huddle_generation) = {
+ let hs = state.huddle()?;
+ (
+ matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active),
+ hs.ephemeral_channel_id.clone(),
+ hs.huddle_generation,
+ )
+ };
+
+ if !is_active {
+ return Ok(());
+ }
+
+ // Detect dead pipelines: if the worker thread has exited (init failure or crash),
+ // clear the pipeline handle so hot-start can retry on the next cycle.
+ {
+ let mut hs = state.huddle()?;
+ if let Some(ref p) = hs.stt_pipeline {
+ if p.is_finished() {
+ hs.stt_pipeline = None;
+ }
+ }
+ if let Some(ref p) = hs.tts_pipeline {
+ if p.is_finished() {
+ hs.tts_pipeline = None;
+ }
+ }
+ }
+ // Re-read after potential cleanup.
+ let (has_stt, has_tts, transcription_enabled) = {
+ let hs = state.huddle()?;
+ (
+ hs.stt_pipeline.is_some(),
+ hs.tts_pipeline.is_some(),
+ hs.transcription_enabled,
+ )
+ };
+
+ // Check if models just became ready (one-shot flags).
+ let stt_ready = models::global_model_manager()
+ .map(|m| m.take_stt_ready())
+ .unwrap_or(false);
+ let tts_ready = models::global_model_manager()
+ .map(|m| m.take_tts_ready())
+ .unwrap_or(false);
+
+ // Start TTS first (so STT can capture tts_cancel).
+ if !has_tts && (tts_ready || models::is_tts_ready()) {
+ if let Err(e) = maybe_start_tts_pipeline(&state).await {
+ eprintln!("buzz-desktop: TTS hotstart failed: {e}");
+ }
+ }
+ if transcription_enabled && !has_stt && (stt_ready || models::is_stt_ready()) {
+ if let Some(eph_id) = &ephemeral_channel_id {
+ if let Err(e) = maybe_start_stt_pipeline(&state, eph_id).await {
+ eprintln!("buzz-desktop: STT hotstart failed: {e}");
+ }
+ }
+ }
+
+ // Periodically refresh agent membership from the relay.
+ // This catches mid-huddle additions/removals by other participants, keeps
+ // STT p-tags authoritative, and auto-enables transcription when the first
+ // agent appears unless the user has already chosen a transcription state.
+ // Throttled independently from the more frequent hotstart poll.
+ //
+ // NOTE: The frontend ALSO polls agent membership independently via
+ // get_huddle_agent_pubkeys. This is intentional — the two polls have
+ // different failure semantics:
+ // - Rust (here): preserves stale list on failure (STT p-tags should not
+ // disappear on a transient network blip).
+ // - React (HuddleContext.tsx): clears list on failure (TTS authorization
+ // must fail-closed — never speak from a stale agent list).
+ //
+ // On Ok: always replace (even with empty — agents may have been removed).
+ // On Err: preserve the existing list (transient failure shouldn't zero it).
+ if let Some(eph_id) = &ephemeral_channel_id {
+ let should_refresh = {
+ let hs = state.huddle()?;
+ match hs.last_agent_refresh {
+ None => true,
+ Some(t) => t.elapsed() >= std::time::Duration::from_secs(15),
+ }
+ };
+ if should_refresh {
+ // Fetch agents (for STT p-tags) before all members (for participant
+ // list) so relay membership queries remain ordered.
+ // Only update the throttle timestamp when at least one fetch succeeds,
+ // so transient failures retry immediately on the next poll cycle.
+ // Fetch both lists before acquiring the lock — no lock held across await.
+ let fresh_agents = fetch_channel_members(eph_id, Some("bot"), &state)
+ .await
+ .ok();
+ let fresh_members = fetch_channel_members(eph_id, None, &state).await.ok();
+ let transcription_auto_enabled = if fresh_agents.is_some() || fresh_members.is_some() {
+ let mut hs = state.huddle()?;
+ if !hs.is_current_huddle(eph_id, huddle_generation) {
+ return Ok(());
+ }
+ if let Some(agents) = fresh_agents {
+ *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents;
+ }
+ if let Some(members) = fresh_members {
+ hs.participants = members;
+ }
+ hs.last_agent_refresh = Some(std::time::Instant::now());
+ hs.maybe_auto_enable_transcription_for_agents()
+ } else {
+ false
+ };
+ if transcription_auto_enabled {
+ start_auto_enabled_transcription(&state, eph_id).await;
+ }
+ }
+ }
+
+ Ok(())
+}
+
pub(crate) async fn post_connect_setup(
state: &AppState,
ephemeral_channel_id: &str,
-) -> Result<(), String> {
+ huddle_generation: u64,
+) -> Result {
+ {
+ let hs = state.huddle()?;
+ if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) {
+ return Ok(PostConnectOutcome::Stale);
+ }
+ }
+
// Hydrate agent pubkeys and participants from relay in parallel
// (authoritative — overrides local guesses).
let (agents_result, all_members_result) = tokio::join!(
fetch_channel_members(ephemeral_channel_id, Some("bot"), state),
fetch_channel_members(ephemeral_channel_id, None, state),
);
- if let Ok(agents) = agents_result {
- let hs = state.huddle()?;
- *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents;
- }
-
- if let Ok(all_members) = all_members_result {
- if !all_members.is_empty() {
- let mut hs = state.huddle()?;
- hs.participants = all_members;
+ let transcription_auto_enabled = {
+ let mut hs = state.huddle()?;
+ if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) {
+ return Ok(PostConnectOutcome::Stale);
}
+ if let Ok(agents) = agents_result {
+ *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents;
+ }
+ if let Ok(all_members) = all_members_result {
+ if !all_members.is_empty() {
+ hs.participants = all_members;
+ }
+ }
+ hs.maybe_auto_enable_transcription_for_agents()
+ };
+
+ if transcription_auto_enabled {
+ state.emit_huddle_state_changed();
}
- // Prepare TTS for agent voice. STT is transcript-specific and starts only
- // when transcription is explicitly enabled.
+ // Prepare voice models. Agent presence may have auto-enabled transcription;
+ // explicit user choices remain authoritative.
if let Some(mgr) = models::global_model_manager() {
mgr.start_tts_download(state.http_client.clone());
+ if state.huddle()?.transcription_enabled {
+ mgr.start_stt_download(state.http_client.clone());
+ }
}
// Connect audio relay WebSocket (Opus encode/decode pipeline).
// This is the core audio path — failure is fatal for the huddle.
let parent_id = {
let hs = state.huddle()?;
+ if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) {
+ return Ok(PostConnectOutcome::Stale);
+ }
hs.parent_channel_id.clone()
};
- let (cancel, pcm_tx) =
- relay_api::connect_audio_relay(ephemeral_channel_id, parent_id.as_deref(), state).await?;
+ let audio_result =
+ relay_api::connect_audio_relay(ephemeral_channel_id, parent_id.as_deref(), state).await;
{
let mut hs = state.huddle()?;
+ if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) {
+ if let Ok((cancel, _)) = audio_result {
+ cancel.cancel();
+ }
+ return Ok(PostConnectOutcome::Stale);
+ }
+ let (cancel, pcm_tx) = audio_result?;
hs.audio_ws_cancel = Some(cancel);
hs.audio_relay_pcm_tx = Some(pcm_tx);
}
- // Start TTS immediately. STT/transcript posting is opt-in and starts only
- // after the user explicitly enables transcription.
+ // Start TTS immediately, then STT when transcription is enabled either by
+ // the user or by authoritative agent membership.
+ if !state
+ .huddle()?
+ .is_current_huddle(ephemeral_channel_id, huddle_generation)
+ {
+ return Ok(PostConnectOutcome::Stale);
+ }
if let Err(e) = maybe_start_tts_pipeline(state).await {
eprintln!("buzz-desktop: TTS pipeline failed to start: {e}");
}
+ if let Err(e) = maybe_start_stt_pipeline(state, ephemeral_channel_id).await {
+ eprintln!("buzz-desktop: STT pipeline failed to start: {e}");
+ }
- Ok(())
+ Ok(PostConnectOutcome::Ready)
}
/// Attempt to start the STT pipeline if models are present.
@@ -83,12 +256,16 @@ pub(crate) async fn maybe_start_stt_pipeline(
state: &AppState,
ephemeral_channel_id: &str,
) -> Result {
- {
+ let huddle_generation = {
let hs = state.huddle()?;
- if !hs.transcription_enabled {
+ if !hs.transcription_enabled
+ || !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active)
+ || hs.ephemeral_channel_id.as_deref() != Some(ephemeral_channel_id)
+ {
return Ok(false);
}
- }
+ hs.huddle_generation
+ };
if !models::is_stt_ready() {
return Ok(false); // Models not downloaded yet — voice-only mode.
@@ -97,21 +274,29 @@ pub(crate) async fn maybe_start_stt_pipeline(
let channel_uuid = parse_channel_uuid(ephemeral_channel_id)?;
- // Atomically claim the construction slot (mirrors tts_starting pattern).
- {
- let hs = state.huddle()?;
- if hs.stt_starting.swap(true, Ordering::AcqRel) {
- return Ok(false); // Another caller is already constructing.
- }
- }
-
- // Grab shared flags, agent pubkeys, and session generation from HuddleState.
+ // Atomically claim construction and grab shared state under one lock.
// If replacing an existing pipeline, bump generation first so the old
// transcription task's next POST sees a stale generation and exits.
// Take the old pipeline OUT of the lock before dropping — Drop joins
// the worker thread (~200ms) and must not block under the mutex.
- let (tts_active, tts_cancel, agent_pubkeys_arc, session_gen, ptt_active_for_stt, old_stt) = {
+ let (
+ tts_active,
+ tts_cancel,
+ agent_pubkeys_arc,
+ session_gen,
+ expected_generation,
+ stt_starting,
+ ptt_active_for_stt,
+ old_stt,
+ ) = {
let mut hs = state.huddle()?;
+ if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) {
+ return Ok(false);
+ }
+ if hs.stt_starting.swap(true, Ordering::AcqRel) {
+ return Ok(false);
+ }
+ let stt_starting = Arc::clone(&hs.stt_starting);
// Invalidate any existing transcription task before replacing the pipeline.
if hs.stt_pipeline.is_some() {
hs.session_generation.fetch_add(1, Ordering::Release);
@@ -130,6 +315,8 @@ pub(crate) async fn maybe_start_stt_pipeline(
Some(Arc::clone(&hs.tts_cancel)),
Arc::clone(&hs.agent_pubkeys),
Arc::clone(&hs.session_generation),
+ hs.session_generation.load(Ordering::Acquire),
+ stt_starting,
ptt,
old,
)
@@ -144,13 +331,11 @@ pub(crate) async fn maybe_start_stt_pipeline(
let (pipeline, text_rx) = match constructed {
Ok(Ok(p)) => p,
Ok(Err(e)) => {
- let hs = state.huddle()?;
- hs.stt_starting.store(false, Ordering::Release);
+ stt_starting.store(false, Ordering::Release);
return Err(e);
}
Err(e) => {
- let hs = state.huddle()?;
- hs.stt_starting.store(false, Ordering::Release);
+ stt_starting.store(false, Ordering::Release);
return Err(format!("spawn_blocking failed: {e}"));
}
};
@@ -158,10 +343,14 @@ pub(crate) async fn maybe_start_stt_pipeline(
{
let mut hs = state.huddle()?;
- hs.stt_starting.store(false, Ordering::Release);
+ stt_starting.store(false, Ordering::Release);
// Phase check: huddle may have been torn down during construction.
if !hs.transcription_enabled
- || !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active)
+ || !hs.is_current_transcription_generation(
+ ephemeral_channel_id,
+ huddle_generation,
+ expected_generation,
+ )
{
return Ok(false);
}
@@ -172,6 +361,17 @@ pub(crate) async fn maybe_start_stt_pipeline(
Ok(true)
}
+/// Start STT after agent presence automatically enables transcription.
+pub(crate) async fn start_auto_enabled_transcription(state: &AppState, ephemeral_channel_id: &str) {
+ if let Some(manager) = models::global_model_manager() {
+ manager.start_stt_download(state.http_client.clone());
+ }
+ if let Err(error) = maybe_start_stt_pipeline(state, ephemeral_channel_id).await {
+ eprintln!("buzz-desktop: auto-enabled STT failed to start: {error}");
+ }
+ state.emit_huddle_state_changed();
+}
+
/// Attempt to start the TTS pipeline if TTS models are present and TTS is enabled.
///
/// Returns `Ok(true)` if the pipeline was started, `Ok(false)` if preconditions
@@ -192,10 +392,44 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result return Ok(false),
};
+ // Avoid resolving and hashing imported voice files on every hot-start poll
+ // when TTS is already disabled or running. The guarded claim below repeats
+ // these checks after the fallible work to close the race.
+ {
+ let huddle = state.huddle()?;
+ if huddle.tts_pipeline.is_some() || !huddle.tts_enabled {
+ return Ok(false);
+ }
+ }
+
+ // Resolve all fallible construction inputs before claiming the sentinel so
+ // an unreadable optional voice registry cannot wedge future start attempts.
+ let output_device = state
+ .huddle_audio
+ .output_device
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .clone();
+ let app = state
+ .app_handle
+ .lock()
+ .map_err(|error| format!("app handle lock poisoned: {error}"))?
+ .clone();
+ let voice_preferences = state
+ .huddle_audio
+ .tts
+ .lock()
+ .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))
+ .map(|settings| settings.voice_preferences.clone())?;
+ let initial_voice = match app {
+ Some(app) => super::tts_settings::pocket_voice_reference(&app, &voice_preferences)?,
+ None => super::tts_settings::bundled_pocket_voice_reference(&voice_preferences),
+ };
+
// Atomically check preconditions and claim the construction slot.
// The sentinel prevents a second caller from starting construction
// while we're building outside the lock.
- let (tts_active, tts_cancel) = {
+ let (tts_active, tts_cancel, tts_starting) = {
let hs = state.huddle()?;
if hs.tts_pipeline.is_some() {
return Ok(false);
@@ -206,18 +440,25 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result Result Result<(), String> {
+ let starting = {
+ let huddle = state.huddle()?;
+ Arc::clone(&huddle.tts_starting)
+ };
+ tokio::time::timeout(Duration::from_secs(15), async {
+ while starting.load(Ordering::Acquire) {
+ tokio::time::sleep(Duration::from_millis(10)).await;
}
- hs.tts_pipeline = Some(pipeline);
+ })
+ .await
+ .map_err(|_| "TTS pipeline startup did not finish before timeout".to_string())?;
+ // The owner clears the sentinel while holding the huddle lock, before it
+ // publishes. Reacquiring that lock ensures publication is visible before
+ // the losing caller looks up the sender.
+ drop(state.huddle()?);
+ Ok(())
+}
+
+struct TtsStartingGuard(Arc);
+
+impl Drop for TtsStartingGuard {
+ fn drop(&mut self) {
+ self.0.store(false, Ordering::Release);
}
+}
+/// Publish a constructed TTS pipeline against the latest settings.
+///
+/// Construction happens outside locks and can overlap a voice change or OFF
+/// transition. Holding the huddle lock while re-reading settings gives either
+/// transition a safe ordering: it updates the installed pipeline afterward,
+/// or this finalizer observes the new setting before publishing.
+fn finalize_tts_pipeline_start(
+ state: &AppState,
+ publish: impl FnOnce(&str, &mut HuddleState),
+) -> Result {
+ let mut huddle = state.huddle()?;
+ huddle.tts_starting.store(false, Ordering::Release);
+ if !huddle.tts_enabled
+ || !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active)
+ || huddle.tts_pipeline.is_some()
+ {
+ return Ok(false);
+ }
+ let app = state
+ .app_handle
+ .lock()
+ .map_err(|error| format!("app handle lock poisoned: {error}"))?
+ .clone();
+ let preferences = state
+ .huddle_audio
+ .tts
+ .lock()
+ .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))
+ .map(|settings| settings.voice_preferences.clone())?;
+ let voice = match app {
+ Some(app) => super::tts_settings::pocket_voice_reference(&app, &preferences)?,
+ None => super::tts_settings::bundled_pocket_voice_reference(&preferences),
+ };
+ publish(&voice, &mut huddle);
Ok(true)
}
+fn should_reselect_constructed_voice(constructed_voice: &str, latest_voice: &str) -> bool {
+ constructed_voice != latest_voice
+}
+
/// Sign an STT transcript event and produce the guarded POST body.
///
/// Factored out of the transcription loop so egress boundary 5 (huddle STT)
@@ -373,3 +678,148 @@ pub(crate) fn spawn_transcription_task(
}
});
}
+
+#[cfg(test)]
+mod tts_start_race_tests {
+ use std::sync::{
+ atomic::{AtomicBool, Ordering},
+ Arc, Barrier, Mutex,
+ };
+ use std::time::Duration;
+
+ use crate::app_state::build_app_state;
+
+ use super::{
+ await_inflight_tts_start, finalize_tts_pipeline_start, should_reselect_constructed_voice,
+ HuddlePhase,
+ };
+
+ #[tokio::test]
+ async fn a_losing_starter_observes_publication_before_resuming() {
+ let state = Arc::new(build_app_state());
+ {
+ let mut huddle = state.huddle().expect("huddle state");
+ huddle.phase = HuddlePhase::Active;
+ huddle.tts_enabled = true;
+ huddle.tts_starting.store(true, Ordering::Release);
+ }
+ let published = Arc::new(AtomicBool::new(false));
+ let owner_state = Arc::clone(&state);
+ let owner_published = Arc::clone(&published);
+ let owner = std::thread::spawn(move || {
+ std::thread::sleep(Duration::from_millis(20));
+ finalize_tts_pipeline_start(&owner_state, |_, _| {
+ owner_published.store(true, Ordering::Release);
+ })
+ });
+
+ await_inflight_tts_start(&state)
+ .await
+ .expect("wait for pipeline owner");
+ assert!(published.load(Ordering::Acquire));
+ assert!(owner.join().expect("pipeline owner").expect("finalize"));
+ }
+
+ #[test]
+ fn constructor_fallback_survives_unchanged_preference_at_publication() {
+ let selected_voice = Mutex::new(super::super::pocket::DEFAULT_VOICE.to_string());
+ let constructed_voice = "eve";
+ let latest_voice = "eve";
+
+ if should_reselect_constructed_voice(constructed_voice, latest_voice) {
+ *selected_voice.lock().expect("selected voice") = latest_voice.to_string();
+ }
+
+ assert_eq!(
+ selected_voice.lock().expect("selected voice").as_str(),
+ super::super::pocket::DEFAULT_VOICE
+ );
+ }
+
+ #[test]
+ fn construction_reconciles_a_voice_selected_while_starting() {
+ let state = Arc::new(build_app_state());
+ {
+ let mut huddle = state.huddle().expect("huddle state");
+ huddle.phase = HuddlePhase::Active;
+ huddle.tts_enabled = true;
+ huddle.tts_starting.store(true, Ordering::Release);
+ }
+
+ let constructed = Arc::new(Barrier::new(2));
+ let publish = Arc::new(Barrier::new(2));
+ let selected_voice = Arc::new(Mutex::new(None));
+ let worker_state = Arc::clone(&state);
+ let worker_constructed = Arc::clone(&constructed);
+ let worker_publish = Arc::clone(&publish);
+ let worker_voice = Arc::clone(&selected_voice);
+ let worker = std::thread::spawn(move || {
+ worker_constructed.wait();
+ worker_publish.wait();
+ finalize_tts_pipeline_start(&worker_state, |voice, _| {
+ *worker_voice.lock().expect("selected voice") = Some(voice.to_string());
+ })
+ });
+
+ constructed.wait();
+ assert!(state
+ .huddle()
+ .expect("huddle state")
+ .tts_starting
+ .load(Ordering::Acquire));
+ state
+ .huddle_audio
+ .tts
+ .lock()
+ .expect("text-to-speech settings")
+ .voice_preferences = vec!["pocket:eve".to_string()];
+ publish.wait();
+
+ assert!(worker.join().expect("starter thread").expect("finalize"));
+ assert_eq!(
+ *selected_voice.lock().expect("selected voice"),
+ Some("eve".to_string())
+ );
+ }
+
+ #[test]
+ fn construction_is_discarded_when_disabled_while_starting() {
+ let state = Arc::new(build_app_state());
+ {
+ let mut huddle = state.huddle().expect("huddle state");
+ huddle.phase = HuddlePhase::Active;
+ huddle.tts_enabled = true;
+ huddle.tts_starting.store(true, Ordering::Release);
+ }
+
+ let constructed = Arc::new(Barrier::new(2));
+ let publish = Arc::new(Barrier::new(2));
+ let did_publish = Arc::new(Mutex::new(false));
+ let worker_state = Arc::clone(&state);
+ let worker_constructed = Arc::clone(&constructed);
+ let worker_publish = Arc::clone(&publish);
+ let worker_did_publish = Arc::clone(&did_publish);
+ let worker = std::thread::spawn(move || {
+ worker_constructed.wait();
+ worker_publish.wait();
+ finalize_tts_pipeline_start(&worker_state, |_, _| {
+ *worker_did_publish.lock().expect("publish flag") = true;
+ })
+ });
+
+ constructed.wait();
+ {
+ let mut huddle = state.huddle().expect("huddle state");
+ huddle.tts_enabled = false;
+ }
+ publish.wait();
+
+ assert!(!worker.join().expect("starter thread").expect("finalize"));
+ assert!(!*did_publish.lock().expect("publish flag"));
+ assert!(!state
+ .huddle()
+ .expect("huddle state")
+ .tts_starting
+ .load(Ordering::Acquire));
+ }
+}
diff --git a/desktop/src-tauri/src/huddle/pocket.rs b/desktop/src-tauri/src/huddle/pocket.rs
index ee1faf928a..fd407103d1 100644
--- a/desktop/src-tauri/src/huddle/pocket.rs
+++ b/desktop/src-tauri/src/huddle/pocket.rs
@@ -1,654 +1,4 @@
-//! Pocket TTS engine wrapper around sherpa-onnx's `OfflineTts`.
-//!
-//! Pocket TTS is a small (~473 MB fp32 ONNX) zero-shot voice-cloning TTS
-//! model from Kyutai. It runs quickly on CPU via sherpa-onnx, replacing the
-//! previous Kokoro-82M engine that also required an espeak-free but
-//! lexicon-heavy G2P pipeline (Misaki + CMUdict).
-//!
-//! Full-precision fp32 sessions, not the ~189 MB int8 quantization we
-//! originally shipped: a direct same-runtime A/B (k2-fsa/sherpa-onnx#3172)
-//! found the int8 ONNX export audibly degraded output quality, and fp32
-//! "significantly improved quality even at 1 step".
-//!
-//! ## Attribution
-//!
-//! - **Model**: Kyutai *Pocket TTS* — Charles, Roebel, et al., 2026.
-//! arXiv:2509.06926. Original repository: .
-//! Licensed CC-BY-4.0.
-//! - **Mimi neural codec**: Kyutai, bundled in the same release. CC-BY-4.0.
-//! - **ONNX export**: KevinAHM —
-//! . CC-BY-4.0.
-//! - **sherpa-onnx repackage**: csukuangfj / k2-fsa —
-//! .
-//! Repackages KevinAHM's export with the file layout sherpa-onnx's
-//! `OfflineTtsPocketModelConfig` expects. CC-BY-4.0.
-//! - **Reference voice WAV** (`reference_sample.wav`): the "Mary
-//! (f, conversation)" preset from the Kyutai TTS demo
-//! (), which maps to `vctk/p333_023_enhanced.wav`
-//! in . CC-BY-4.0, base recording
-//! from the VCTK corpus, enhanced by ai-coustics.
-//!
-//! Buzz ships these files unmodified; see the on-disk `MODEL_LICENSE.txt`
-//! sidecar written by `huddle::models` during install for the canonical
-//! CC-BY-4.0 §3(a)(1) attribution block.
-//!
-//! ## Engine-module contract (see `huddle::tts`)
-//!
-//! `pocket.rs` exposes a fixed surface used by `tts.rs`. Mirroring this
-//! contract is what lets the TTS pipeline stay engine-agnostic:
-//!
-//! - `SAMPLE_RATE: u32` — engine output sample rate in Hz.
-//! - `DEFAULT_VOICE: &str` — default voice name (without extension).
-//! - `VOICE_FILE_EXT: &str` — extension for per-voice files on disk.
-//! - `load_text_to_speech(model_dir)` → `Result`
-//! - `load_voice_style(path)` → `Result`
-//! - `Engine::synth_chunk(&self, text, lang, &VoiceStyle, steps)`
-//! → `Result, String>`
-//!
-//! `lang` and `steps` are accepted for API compatibility with the previous
-//! Kokoro engine but are unused — Pocket TTS does its own language ID from
-//! the input text and is not a diffusion model (consistency LM, one step).
-//! There is no speed knob: sherpa-onnx's `GenerationConfig.speed` is only
-//! read by some model families (vits), never by the Pocket impl
-//! (`offline-tts-pocket-impl.h` — zero references), and upstream pocket-tts
-//! has no speed parameter either.
-
-use std::collections::HashMap;
-use std::path::{Path, PathBuf};
-
-use sherpa_onnx::{GenerationConfig, OfflineTts, OfflineTtsConfig, Wave};
-
-// ── Engine-module contract: public consts ─────────────────────────────────────
-
-/// Pocket TTS emits 24 kHz mono PCM. Matches the previous Kokoro output rate,
-/// so the rodio sink and inter-sentence silence buffer in `tts.rs` remain valid.
-pub const SAMPLE_RATE: u32 = 24_000;
-
-/// Name (without extension) of the bundled reference voice. The model directory
-/// is expected to contain `.` after install.
-pub const DEFAULT_VOICE: &str = "reference_sample";
-
-/// Voice files for Pocket TTS are reference audio (WAV). Distinct from the
-/// Kokoro `.bin` style vectors — the model conditions on raw waveform samples,
-/// not a precomputed embedding, so the extension change is honest.
-pub const VOICE_FILE_EXT: &str = "wav";
-
-// ── Tuning ────────────────────────────────────────────────────────────────────
-
-/// Single-threaded ONNX execution for predictable CPU contention with the STT
-/// pipeline. Matches `STT_NUM_THREADS` in `stt.rs`; raise only if a benchmark
-/// argues for it.
-const TTS_NUM_THREADS: i32 = 1;
-
-/// LRU cache size for cloned voice embeddings inside the sherpa-onnx engine.
-/// We bind to one voice per pipeline today, but the upstream example uses 16
-/// and the cost is negligible — keep room for future multi-voice support.
-const VOICE_EMBEDDING_CACHE_CAPACITY: i32 = 16;
-
-/// Pocket TTS is a consistency-based LM. Generation quality saturates at one
-/// denoising step — the upstream `GenerationConfig` default of 5 multiplies
-/// synthesis time by ~5× with no audible benefit on this model.
-const SYNTH_NUM_STEPS: i32 = 1;
-
-/// Leave the generated audio's silences untouched (1.0 is the identity).
-///
-/// sherpa-onnx's `ScaleSilence` (`offline-tts.cc`) is *not* pre/post padding
-/// control: it finds every interior silence run ≥ 0.2 s (|s| ≤ 0.01) and
-/// multiplies its length by this factor. The previous value of 0.0 — set
-/// under the mistaken belief it disabled lead-in/lead-out padding — deleted
-/// every natural pause inside an utterance: clause breaks, breaths, the gap
-/// after a comma. Words slammed together and endings cut abruptly. The
-/// reference Pocket TTS pipeline does not post-process silence at all;
-/// 1.0 restores parity.
-const SYNTH_SILENCE_SCALE: f32 = 1.0;
-
-/// sherpa-onnx upstream default for `max_frames` (LM steps), in
-/// `offline-tts-pocket-impl.h:Generate`. 500 steps ≈ 40 s of audio at the
-/// Mimi 12.5 Hz frame rate. Referenced only by the regression test below;
-/// production code path never raises (or even reads) this value — we just
-/// leave sherpa-onnx's own default in place by not setting the override.
-#[cfg(test)]
-const SHERPA_ONNX_MAX_FRAMES_DEFAULT: i32 = 500;
-
-/// Tight `max_frames` we ask for on short, padded prompts to bound the
-/// original "monster breathing" runaway. 100 LM steps ≈ 8 s of audio —
-/// roomy for any one-to-four-word utterance the user is likely to elicit
-/// while still well short of the 40 s upstream default. Chosen with slack so
-/// we never *truncate* a legitimate short reply.
-const SHORT_PROMPT_MAX_FRAMES: i32 = 100;
-
-/// Word-count threshold (inclusive) below which we pad the prompt with
-/// leading spaces and cap `max_frames` tighter than the upstream default.
-/// Matches upstream `pocket_tts.models.tts_model.prepare_text_prompt`. Above
-/// this threshold we leave sherpa-onnx's own defaults in place — overriding
-/// them caused the "first 'yep' is just static" regression seen on
-/// 2026-05-18, where dropping `frames_after_eos` below the upstream default
-/// of 3 clipped the leading audio of multi-clause sentences.
-const SHORT_PROMPT_WORD_THRESHOLD: usize = 4;
-
-/// Number of leading spaces prepended to short prompts. The upstream Python
-/// uses exactly 8 — keep parity rather than tuning blindly.
-///
-/// This is upstream's *only* mitigation for the FlowLM cold-start smear on
-/// short utterances (kyutai-labs/pocket-tts #91, #70): the autoregressive
-/// generation has a 2–3 step "settle" period where the first phoneme can be
-/// smeared. A previous revision added a sacrificial `". . "` prefix plus an
-/// amplitude-threshold trim to strip the rendered prefix from the output —
-/// but the trim's absolute threshold (0.02 against raw peaks of ~0.076) sat
-/// in soft-onset territory and could eat real word starts, and its tuning
-/// was calibrated against `silence_scale = 0.0` audio. Deleted in favour of
-/// upstream parity: accept the occasional smeared first syllable rather
-/// than risk trimming real speech.
-const SHORT_PROMPT_PAD_SPACES: usize = 8;
-
-/// sherpa-onnx's documented `frames_after_eos` default. We deliberately do
-/// *not* override this knob — the previous attempt to bump it for short
-/// inputs and lower it for long inputs lowered it below the upstream default
-/// of 3, which clipped the leading audio of multi-clause sentences (the
-/// "first 'yep' is static" regression). The constant exists only for the
-/// regression test below. Source: `offline-tts-pocket-impl.h:Generate`.
-#[cfg(test)]
-const SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT: i32 = 3;
-
-// ── ONNX file names (five Pocket TTS sessions plus two JSON tables) ───────────
-
-const FILE_LM_MAIN: &str = "lm_main.onnx";
-const FILE_LM_FLOW: &str = "lm_flow.onnx";
-const FILE_ENCODER: &str = "encoder.onnx";
-const FILE_DECODER: &str = "decoder.onnx";
-const FILE_TEXT_COND: &str = "text_conditioner.onnx";
-const FILE_VOCAB: &str = "vocab.json";
-const FILE_TOKEN_SCORES: &str = "token_scores.json";
-
-// ── Voice style ───────────────────────────────────────────────────────────────
-
-/// Loaded reference voice — normalised f32 PCM samples plus their sample rate.
-///
-/// Pocket TTS takes a reference waveform per generation call (not a
-/// precomputed style embedding), so we keep the samples in memory and clone
-/// the small `Vec` into each `GenerationConfig` rather than re-reading the
-/// WAV from disk on every sentence.
-#[derive(Debug, Clone)]
-pub struct VoiceStyle {
- samples: Vec