From 5c5afe9b7730c48e90426989c3486a042446467f Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Mon, 13 Jul 2026 23:02:51 +0530 Subject: [PATCH 1/5] build(voice): add default-on `voice` feature gating hound + lettre (#4803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a compile-time `voice` Cargo feature (default-ON) that pulls the now-optional `hound` (WAV I/O) and `lettre` (podcast email) dependencies. Default builds are byte-identical; slim builds drop the pair via `--no-default-features`. whisper-rs / cpal are intentionally left untouched (inference domain — future gate). --- Cargo.toml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c0fc36c899..6e630b680a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -217,7 +217,7 @@ sysinfo = { version = "0.33", default-features = false, features = ["system"] } keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } clap = { version = "4.5", features = ["derive"] } clap_complete = "4.5" -lettre = { version = "0.11.22", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"] } +lettre = { version = "0.11.22", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"], optional = true } mail-parser = "0.11.2" async-imap = { version = "0.11", features = ["runtime-tokio"], default-features = false } axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"] } @@ -233,7 +233,7 @@ whisper-rs = "0.16" image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } tempfile = "3" cpal = "0.15" -hound = "3.5" +hound = { version = "3.5", optional = true } enigo = "0.3" arboard = "3" rdev = "0.5" @@ -334,12 +334,23 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter"] +default = ["tokenjuice-treesitter", "voice"] # AST-aware code compression (tree-sitter Rust/TS/Python grammars; C build). # On by default; disable to fall back to the brace-depth heuristic. tokenjuice-treesitter = [ "tinyjuice/tinyjuice-treesitter", ] +# Voice + audio_toolkit domains: STT/TTS providers, the standalone dictation +# server, always-on listening, and podcast audio generation/email delivery. +# Default-ON — the desktop app always ships with voice. Slim / headless builds +# opt out via `--no-default-features --features ""`, +# which also drops the exclusive `hound` (WAV I/O) + `lettre` (podcast email) +# dependencies. Composes with the runtime `DomainSet::voice` flag (#4796): the +# feature narrows the compile-time surface, `DomainSet` gates it at runtime. +# NOTE: this gate does NOT drop whisper-rs / llama / cpal — those live in the +# inference domain (shared with accessibility for cpal) and await a separate +# `inference` gate. +voice = ["dep:hound", "dep:lettre"] sandbox-landlock = ["dep:landlock"] sandbox-bubblewrap = [] peripheral-rpi = ["dep:rppal"] From bc7c765635258f989d129316143d48d24e10475f Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Mon, 13 Jul 2026 23:02:59 +0530 Subject: [PATCH 2/5] refactor(voice): make voice module a feature-gated facade with disabled stub (#4803) Keep `pub mod voice;` always compiled as a facade: real submodules and the inference::voice re-exports are gated `#[cfg(feature = "voice")]`, and a new `#[cfg(not(feature = "voice"))] mod stub` mirrors the public surface that always-on / other-gated callers depend on (server, dictation_listener, streaming, reply_speech, cloud_transcribe, cli, create_stt_provider, effective_stt_provider, publish_ptt_transcript_committed) with no-op / None / disabled-error bodies. Callers need no per-call cfg; the disabled build catches any signature drift. --- src/openhuman/voice/mod.rs | 49 ++++++ src/openhuman/voice/stub.rs | 292 ++++++++++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 src/openhuman/voice/stub.rs diff --git a/src/openhuman/voice/mod.rs b/src/openhuman/voice/mod.rs index 14c1c218ab..993dca1479 100644 --- a/src/openhuman/voice/mod.rs +++ b/src/openhuman/voice/mod.rs @@ -8,46 +8,95 @@ //! hallucination, streaming, postprocess) now live under //! `crate::openhuman::inference::voice` so all inference concerns share a //! single domain root. +//! +//! ## Compile-time gate (`voice` feature) +//! +//! `pub mod voice;` is ALWAYS compiled — it is a facade. The real +//! implementation (the submodules below and the `inference::voice` re-exports) +//! is gated behind the default-ON `voice` Cargo feature. When the feature is +//! off, [`stub`] takes its place and exposes the same public surface that +//! always-on / other-gated callers depend on (`server`, `dictation_listener`, +//! `streaming`, `reply_speech`, `cloud_transcribe`, `create_stt_provider`, +//! `effective_stt_provider`, `publish_ptt_transcript_committed`) with +//! no-op / disabled-error bodies. Keeping the two surfaces in lockstep is +//! enforced by the disabled-build check +//! (`cargo check --no-default-features --features ""`): any +//! signature drift fails that build. +#[cfg(feature = "voice")] pub mod always_on; +#[cfg(feature = "voice")] pub mod audio_capture; +#[cfg(feature = "voice")] pub mod bus; +#[cfg(feature = "voice")] pub use bus::publish_ptt_transcript_committed; +#[cfg(feature = "voice")] pub(crate) mod cli; +#[cfg(feature = "voice")] pub mod command_router; +#[cfg(feature = "voice")] pub mod dictation_listener; +#[cfg(feature = "voice")] pub mod factory; +#[cfg(feature = "voice")] pub mod hotkey; +#[cfg(feature = "voice")] mod ops; +#[cfg(feature = "voice")] pub mod reply_speech; +#[cfg(feature = "voice")] mod schemas; +#[cfg(feature = "voice")] pub mod server; +#[cfg(feature = "voice")] pub mod text_input; +#[cfg(feature = "voice")] mod types; // Re-export the inference-side voice modules so `voice::local_speech`, // `voice::local_transcribe`, etc. continue to resolve for existing callers. +#[cfg(feature = "voice")] pub use crate::openhuman::inference::voice::cloud_transcribe; +#[cfg(feature = "voice")] pub use crate::openhuman::inference::voice::hallucination; +#[cfg(feature = "voice")] pub use crate::openhuman::inference::voice::local_speech; +#[cfg(feature = "voice")] pub use crate::openhuman::inference::voice::local_transcribe; +#[cfg(feature = "voice")] pub use crate::openhuman::inference::voice::postprocess; +#[cfg(feature = "voice")] pub use crate::openhuman::inference::voice::streaming; +#[cfg(feature = "voice")] pub use factory::{ create_stt_provider, create_tts_provider, default_stt_provider, default_tts_provider, effective_stt_provider, effective_tts_provider, ExternalSttProvider, ExternalTtsProvider, SttProvider, SttResult, TtsProvider, DEFAULT_PIPER_VOICE, DEFAULT_WHISPER_MODEL, WHISPER_MODEL_PRESETS, }; +#[cfg(feature = "voice")] pub use ops::*; +#[cfg(feature = "voice")] pub use schemas::{all_voice_controller_schemas, all_voice_registered_controllers, voice_schemas}; +#[cfg(feature = "voice")] pub use types::{VoiceSpeechResult, VoiceStatus, VoiceTtsResult}; /// Default Whisper-v1 model id sent to the backend cloud STT proxy. Kept /// here (rather than in `cloud_transcribe.rs`) so the factory module can /// reach it via the public `voice::` surface without re-exporting an /// internal constant. +#[cfg(feature = "voice")] pub(crate) fn cloud_transcribe_default_model() -> &'static str { "whisper-v1" } + +// --------------------------------------------------------------------------- +// Disabled facade — compiled only when the `voice` feature is OFF. +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "voice"))] +mod stub; +#[cfg(not(feature = "voice"))] +pub use stub::*; diff --git a/src/openhuman/voice/stub.rs b/src/openhuman/voice/stub.rs new file mode 100644 index 0000000000..d9ee2d2e2d --- /dev/null +++ b/src/openhuman/voice/stub.rs @@ -0,0 +1,292 @@ +//! Disabled-voice facade. +//! +//! Compiled only when the `voice` Cargo feature is OFF (see the gate in +//! [`super`]). It mirrors the subset of the real `voice` public surface that +//! always-on / other-gated callers depend on, with no-op / disabled-error +//! bodies so the crate still compiles, boots, and serves `/rpc` without the +//! voice + audio_toolkit domains. +//! +//! The signatures here MUST match the real ones exactly (return types included). +//! The disabled build +//! (`cargo check --no-default-features --features ""`) is the +//! only thing that catches drift — if a real signature changes, update the +//! mirror below until that build is green again. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::openhuman::config::Config; +use crate::rpc::RpcOutcome; + +/// Error text returned by every disabled-path operation that must yield a +/// `Result`. Shared so callers/log-greps see one stable string. +const DISABLED_MSG: &str = "voice feature disabled at compile time"; + +// --------------------------------------------------------------------------- +// Provider factory surface (mirrors `factory::*` re-exported at the voice root) +// --------------------------------------------------------------------------- + +/// Common STT result shape. Mirrors [`super::factory::SttResult`] (real build). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SttResult { + pub text: String, + /// Lowercase provider id — kept for wire-shape parity with the real type. + pub provider: String, +} + +/// Speech-to-text provider abstraction. Object-safe (via `async_trait`) so +/// `Box` remains nameable at call sites; no concrete +/// implementation exists when voice is compiled out. +#[async_trait] +pub trait SttProvider: Send + Sync { + fn name(&self) -> &'static str; + + async fn transcribe( + &self, + config: &Config, + audio_base64: &str, + mime_type: Option<&str>, + file_name: Option<&str>, + language: Option<&str>, + ) -> Result, String>; +} + +/// Resolve the effective STT provider string. With voice disabled the value is +/// never used to build a real provider; the config-independent default keeps +/// logging/telemetry callers seeing a sensible string. +pub fn effective_stt_provider(_config: &Config) -> String { + "cloud".to_string() +} + +/// Always errors: no STT provider can be constructed when voice is compiled +/// out. Callers `?`-propagate the error, so the boxed provider is never used. +pub fn create_stt_provider( + _provider: &str, + _model: &str, + _config: &Config, +) -> anyhow::Result> { + Err(anyhow::anyhow!(DISABLED_MSG)) +} + +// --------------------------------------------------------------------------- +// Event bus surface (mirrors `bus::publish_ptt_transcript_committed`) +// --------------------------------------------------------------------------- + +/// No-op: with voice disabled there is no PTT dictation path to announce. +pub fn publish_ptt_transcript_committed( + _thread_id: String, + _session_id: u64, + _text_len: usize, + _held_ms: u64, + _finalized_by_watchdog: bool, +) { + log::debug!("[voice-stub] publish_ptt_transcript_committed ignored (voice disabled)"); +} + +// --------------------------------------------------------------------------- +// cli::run_standalone_subcommand (kept registered as a CLI adapter) +// --------------------------------------------------------------------------- + +pub mod cli { + /// Disabled: the standalone dictation server needs the voice stack. Returns + /// an error so `openhuman voice` reports the feature is off instead of the + /// subcommand silently disappearing from the CLI registry. + pub fn run_standalone_subcommand(_args: &[String]) -> anyhow::Result<()> { + Err(anyhow::anyhow!(super::DISABLED_MSG)) + } +} + +// --------------------------------------------------------------------------- +// server::{start_if_enabled, try_global_server} +// --------------------------------------------------------------------------- + +pub mod server { + use std::sync::Arc; + + use crate::openhuman::config::Config; + + /// Opaque handle; never actually constructed when voice is disabled, but + /// kept nameable so `Option>` call sites type-check. + pub struct VoiceServer; + + impl VoiceServer { + /// No-op stop — unreachable (no server is ever created). + pub async fn stop(&self) {} + } + + /// No-op: there is no voice server to start. + pub async fn start_if_enabled(_config: &Config) {} + + /// Always `None`: no global voice server exists. + pub fn try_global_server() -> Option> { + None + } +} + +// --------------------------------------------------------------------------- +// dictation_listener::{start_if_enabled, stop, subscribe_*} +// --------------------------------------------------------------------------- + +pub mod dictation_listener { + use once_cell::sync::Lazy; + use serde::Serialize; + use tokio::sync::broadcast; + + use crate::openhuman::config::Config; + + /// Mirrors the real `DictationEvent` wire shape so the Socket.IO bridge's + /// `serde_json::to_value(&event)` + `event.event_type` access type-check. + #[derive(Debug, Clone, Serialize)] + pub struct DictationEvent { + #[serde(rename = "type")] + pub event_type: String, + pub hotkey: String, + pub activation_mode: String, + } + + // Senders are kept alive for the process lifetime so subscribers park + // (never receive) rather than seeing an immediate `Closed`, matching the + // real always-open broadcast bus. Nothing is ever published. + static DICTATION_BUS: Lazy> = + Lazy::new(|| broadcast::channel(1).0); + static TRANSCRIPTION_BUS: Lazy> = + Lazy::new(|| broadcast::channel(1).0); + + /// Subscribe to (never-emitting) dictation events. + pub fn subscribe_dictation_events() -> broadcast::Receiver { + DICTATION_BUS.subscribe() + } + + /// Subscribe to (never-emitting) transcription results. + pub fn subscribe_transcription_results() -> broadcast::Receiver { + TRANSCRIPTION_BUS.subscribe() + } + + /// No-op: no hotkey listener is started. + pub async fn start_if_enabled(_config: &Config) {} + + /// No-op: nothing to stop. + pub fn stop() {} +} + +// --------------------------------------------------------------------------- +// always_on::{start_if_enabled, stop} +// --------------------------------------------------------------------------- + +pub mod always_on { + use crate::openhuman::config::Config; + + /// No-op: always-on listening does not exist when voice is disabled. + pub async fn start_if_enabled(_config: &Config) {} + + /// No-op: nothing to stop. + pub fn stop() {} +} + +// --------------------------------------------------------------------------- +// streaming::handle_dictation_ws (re-exported from inference::voice in real) +// --------------------------------------------------------------------------- + +pub mod streaming { + use std::sync::Arc; + + use axum::extract::ws::WebSocket; + + use crate::openhuman::config::Config; + + /// Drop the upgraded socket immediately — there is nothing to transcribe. + pub async fn handle_dictation_ws(_socket: WebSocket, _config: Arc) {} +} + +// --------------------------------------------------------------------------- +// reply_speech::{synthesize_reply, ReplySpeechOptions, ReplySpeechResult, ...} +// --------------------------------------------------------------------------- + +pub mod reply_speech { + use serde::{Deserialize, Serialize}; + use serde_json::Value; + + use crate::openhuman::config::Config; + use crate::rpc::RpcOutcome; + + /// One frame on the viseme timeline. Mirrors the real type. + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] + pub struct VisemeFrame { + pub viseme: String, + pub start_ms: u64, + pub end_ms: u64, + } + + /// Char-level timing frame. Mirrors the real type. + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] + pub struct AlignmentFrame { + pub char: String, + pub start_ms: u64, + pub end_ms: u64, + } + + /// Normalized TTS response. Mirrors the real type. + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct ReplySpeechResult { + pub audio_base64: String, + pub audio_mime: String, + pub visemes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub alignment: Option>, + } + + /// Caller-tunable knobs. Mirrors the real type (fields + `Default`). + #[derive(Debug, Default, Clone)] + pub struct ReplySpeechOptions { + pub voice_id: Option, + pub model_id: Option, + pub output_format: Option, + pub voice_settings: Option, + } + + /// Disabled: reply-speech synthesis is unavailable when voice is compiled + /// out. Callers log the error and skip the spoken reply. + pub async fn synthesize_reply( + _config: &Config, + _text: &str, + _opts: &ReplySpeechOptions, + ) -> Result, String> { + Err(super::DISABLED_MSG.to_string()) + } +} + +// --------------------------------------------------------------------------- +// cloud_transcribe::{transcribe_cloud, CloudTranscribeOptions, ...} +// (re-exported from inference::voice in the real build) +// --------------------------------------------------------------------------- + +pub mod cloud_transcribe { + use serde::{Deserialize, Serialize}; + + use crate::openhuman::config::Config; + use crate::rpc::RpcOutcome; + + /// Caller-tunable knobs. Mirrors the real type (fields + `Default`). + #[derive(Debug, Default, Clone)] + pub struct CloudTranscribeOptions { + pub model: Option, + pub language: Option, + pub mime_type: Option, + pub file_name: Option, + } + + /// Transcription result. Mirrors the real type. + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct CloudTranscribeResult { + pub text: String, + } + + /// Disabled: cloud STT is unavailable when voice is compiled out. + pub async fn transcribe_cloud( + _config: &Config, + _audio_base64: &str, + _opts: &CloudTranscribeOptions, + ) -> Result, String> { + Err(super::DISABLED_MSG.to_string()) + } +} From d1b6e91a77c2c651824e307f2240cc77a6aa4c76 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Mon, 13 Jul 2026 23:03:05 +0530 Subject: [PATCH 3/5] feat(voice): gate audio_toolkit domain, registration, and agent tools behind voice (#4803) Compile out the `audio_toolkit` module, its controller + voice-controller registration in core::all, and the three podcast agent tools + tool re-export when the `voice` feature is off. The voice CLI adapter stays registered so `openhuman voice` returns a clear disabled error via the facade stub. Add registry assertions covering both feature configs (controllers present when on, absent when off). --- src/core/all.rs | 10 ++++++++-- src/core/all_tests.rs | 33 +++++++++++++++++++++++++++++++++ src/openhuman/mod.rs | 1 + src/openhuman/tools/mod.rs | 1 + src/openhuman/tools/ops.rs | 6 ++++++ 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/core/all.rs b/src/core/all.rs index 06a44d060e..e7697117be 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -167,6 +167,10 @@ fn internal_registry() -> &'static [GroupedController] { /// Returns a reference to the global CLI adapter registry. fn cli_adapters() -> &'static [RegisteredCliAdapter] { CLI_ADAPTERS.get_or_init(|| { + // The `voice` namespace stays registered regardless of the `voice` + // feature: with the feature off, `voice::cli::run_standalone_subcommand` + // resolves to the facade stub, which returns a "voice disabled" error so + // `openhuman voice` fails gracefully instead of the subcommand vanishing. vec![RegisteredCliAdapter { namespace: "voice", handler: crate::openhuman::voice::cli::run_standalone_subcommand, @@ -202,7 +206,8 @@ fn build_registered_controllers() -> Vec { DomainGroup::Platform, crate::openhuman::app_state::all_app_state_registered_controllers(), ); - // Audio generation + podcast-style email delivery + // Audio generation + podcast-style email delivery (gated with voice). + #[cfg(feature = "voice")] push( &mut controllers, DomainGroup::Voice, @@ -632,7 +637,8 @@ fn build_registered_controllers() -> Vec { DomainGroup::Platform, crate::openhuman::text_input::all_text_input_registered_controllers(), ); - // Voice transcription and synthesis + // Voice transcription and synthesis (gated behind the `voice` feature). + #[cfg(feature = "voice")] push( &mut controllers, DomainGroup::Voice, diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index 044e930e20..08412d4d26 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -201,6 +201,39 @@ fn all_controller_schemas_matches_registered_count() { assert_eq!(schemas.len(), controllers.len()); } +/// With the `voice` feature on (the default), the voice + audio_toolkit +/// controllers are compiled in and registered — the desktop build is +/// byte-identical. +#[test] +#[cfg(feature = "voice")] +fn voice_and_audio_controllers_registered_when_feature_on() { + let schemas = all_controller_schemas(); + assert!( + schemas.iter().any(|s| s.namespace == "voice"), + "voice controllers must be registered when the `voice` feature is on" + ); + assert!( + schemas.iter().any(|s| s.namespace == "audio_toolkit"), + "audio_toolkit controllers must be registered when the `voice` feature is on" + ); +} + +/// With the `voice` feature off, both domains are compiled out: their +/// controllers never enter the registry, so voice/audio RPC methods are +/// unknown-method and absent from `/schema`. This is the compile-time +/// stub-facade correctness gate (see `openhuman::voice::stub`). +#[test] +#[cfg(not(feature = "voice"))] +fn voice_and_audio_controllers_absent_when_feature_off() { + let schemas = all_controller_schemas(); + assert!( + !schemas + .iter() + .any(|s| s.namespace == "voice" || s.namespace == "audio_toolkit"), + "voice/audio_toolkit controllers must be compiled out when the `voice` feature is off" + ); +} + #[test] fn schema_for_rpc_method_finds_known_method() { let schema = schema_for_rpc_method("openhuman.health_snapshot"); diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index d0780a6f35..74f8442046 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -28,6 +28,7 @@ pub mod announcements; pub mod app_state; pub mod approval; pub mod artifacts; +#[cfg(feature = "voice")] pub mod audio_toolkit; pub mod autocomplete; pub mod billing; diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 82f1e9b0b1..58aef6e4a6 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -15,6 +15,7 @@ pub use crate::openhuman::agent::tools::*; pub use crate::openhuman::agent_memory::tools::*; pub use crate::openhuman::agent_orchestration::tools::*; pub use crate::openhuman::artifacts::tools::*; +#[cfg(feature = "voice")] pub use crate::openhuman::audio_toolkit::tools::*; pub use crate::openhuman::billing::tools::*; pub use crate::openhuman::codegraph::tools::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index f4c9e9c29d..d96dac8688 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -402,11 +402,17 @@ pub fn all_tools_with_runtime( security.clone(), action_dir.to_path_buf(), )), + // Audio-toolkit podcast tools — gated with the `voice` feature (they + // live in the `audio_toolkit` domain, which is compiled out when voice + // is disabled). + #[cfg(feature = "voice")] Box::new(AudioGeneratePodcastTool::new( config.clone(), security.clone(), )), + #[cfg(feature = "voice")] Box::new(AudioEmailPodcastTool::new(config.clone(), security.clone())), + #[cfg(feature = "voice")] Box::new(AudioGenerateAndEmailPodcastTool::new( config.clone(), security.clone(), From 9b8c5c1c450a03ce49311ac87da732e23ba732b1 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Mon, 13 Jul 2026 23:03:10 +0530 Subject: [PATCH 4/5] docs(voice): document the voice feature gate and slim-profile convention (#4803) Add a Compile-time domain gates section to AGENTS.md: the default-on `voice` feature, the facade/stub pathfinder pattern, the slim-profile `--no-default-features --features ""` convention, and the scope correction that whisper-rs / llama / cpal are NOT dropped by this gate (inference domain, future gate). --- AGENTS.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 756359bdab..e4d86eb785 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -216,6 +216,26 @@ Two independent runtime axes on `CoreBuilder` (`src/core/runtime/builder.rs`): - **`ServiceSet`** selects which *background services / transports* run (`rpc_http`, `socketio`, `cron`, `channels`, `heartbeat`, …). Presets: `desktop()` / `headless_api()` / `none()`. - **`DomainSet`** selects which *domain families* exist at runtime, one flag per `DomainGroup` (`src/core/all.rs`). Presets: `full()` (default — byte-identical to before #4796), `harness()` (agent + memory + threads + config + security only), `none()`. Every controller is tagged with its `DomainGroup` at the single registration site in `src/core/all.rs`; the live surface (controllers/`/schema`/dispatch, agent tools, stores, subscribers) is filtered by the ambient `CoreContext::domains()`. A gated domain's controllers become unknown-method, its agent tools absent, its stores/subscribers uninitialized. `examples/embed_headless.rs` uses `DomainSet::harness()`. Per-gate Cargo `[features]` (children #4797–#4804) narrow the compile-time surface further; `DomainSet` is the runtime axis they compose with. +### Compile-time domain gates (Cargo `[features]`) + +Per-domain Cargo features drop whole domains **at compile time** (smaller binary, fewer deps), composing with the runtime `DomainSet` axis above. Each gate is **default-ON**, so the desktop build is byte-identical; slim builds opt out explicitly. + +**Slim-profile convention** (no `full` meta-feature): build slim variants with `cargo build --no-default-features --features ""`. This mirrors the existing standalone-feature style (`sandbox-landlock`, `browser-native`, …). Example — everything except voice: + +```bash +# check / build without the voice + audio_toolkit domains +GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ + --no-default-features --features tokenjuice-treesitter +``` + +| Feature | Default | Gates | Drops deps | +| ------- | ------- | ----- | ---------- | +| `voice` | ON | `openhuman::voice` + `openhuman::audio_toolkit` domains — STT/TTS providers, dictation server, always-on listening, podcast audio + email | `hound`, `lettre` | + +**Facade pattern (pathfinder for the other gates).** `pub mod voice;` is **always compiled** as a facade: the real submodules are `#[cfg(feature = "voice")]`, and a `#[cfg(not(feature = "voice"))] mod stub;` (`src/openhuman/voice/stub.rs`) re-exposes the same public surface that always-on / other-gated callers use (`server`, `dictation_listener`, `streaming`, `reply_speech`, `cloud_transcribe`, `cli`, `create_stt_provider`, `effective_stt_provider`, `publish_ptt_transcript_committed`) with no-op / `None` / disabled-error bodies. Callers therefore do **not** need per-call `#[cfg]`. When voice is off: the voice/audio controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the `audio_generate_podcast` agent tools are absent, and `openhuman voice` returns a "voice disabled" error. Stub signatures must match the real ones exactly — the disabled build (`--no-default-features --features tokenjuice-treesitter`) is the **only** thing that catches drift, so run it before pushing any change to the voice surface. + +**Scope note:** the `voice` gate does **not** drop `whisper-rs` / `llama` / `cpal`. Those live in the inference domain (`src/openhuman/inference/local/service/whisper_engine.rs`; `cpal` is shared with accessibility) and await a separate future `inference` gate. The issue-level DoD line claiming whisper is dropped is superseded by this scope correction. + ### Event bus (`src/core/event_bus/`) Typed pub/sub + native request/response. Both singletons — use module-level functions. From d48ae12fd17b2d07e23a26818a10e227d84681c9 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Mon, 13 Jul 2026 23:08:36 +0530 Subject: [PATCH 5/5] ci(voice): smoke-check the voice-gate-disabled build (#4803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DoD for #4803 requires CI to build the core with the gate OFF — the disabled build is the only thing that catches stub-facade signature drift. Adds a rust-feature-gate-smoke lane running `cargo check --no-default-features --features tokenjuice-treesitter`. Pathfinder lane; extend the --features list as sibling gates land. --- .github/workflows/ci-lite.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index f51fddf2e0..870276ae6d 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -351,6 +351,40 @@ jobs: - name: Run clippy (core crate) run: bash scripts/ci-cancel-aware.sh cargo clippy -p openhuman + # Feature-gate smoke: proves the core still compiles with a domain gate turned + # OFF. The disabled build is the ONLY thing that catches stub-facade signature + # drift (see AGENTS.md "Compile-time domain gates"), so it must run in CI, not + # just locally. Pathfinder lane for #4803 (voice); extend the --features list + # as sibling gates (#4797–#4802, #4804) land. + rust-feature-gate-smoke: + name: Rust Feature-Gate Smoke (gates off) + needs: [changes] + if: needs.changes.outputs['rust-core'] == 'true' || needs.changes.outputs['rust-tauri'] == 'true' + runs-on: ubuntu-22.04 + timeout-minutes: 25 + container: + image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 + env: + CARGO_INCREMENTAL: "0" + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 1 + persist-credentials: false + submodules: recursive + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + cache-on-failure: true + shared-key: pr-rust-feature-gate-smoke + + - name: Check core builds with the voice gate disabled + run: bash scripts/ci-cancel-aware.sh cargo check --manifest-path Cargo.toml --no-default-features --features tokenjuice-treesitter + rust-core-coverage: name: Rust Core Coverage (cargo-llvm-cov) needs: [changes, rust-quality]