Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci-lite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Comment on lines +359 to +361

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wire feature-gate smoke into the PR gate

In PRs where the repository rules only require the historical PR CI Gate check (the workflow comment says that name is kept for the ruleset), this new smoke job can fail without blocking merge because pr-ci-gate neither lists rust-feature-gate-smoke in its needs nor checks its result. That defeats the stated purpose of this lane: a future stub-signature drift in --no-default-features --features tokenjuice-treesitter could be red in CI while the required gate still passes.

Useful? React with 👍 / 👎.

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]
Expand Down
20 changes: 20 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<explicit list of gates you want>"`. 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.
Expand Down
17 changes: 14 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand All @@ -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"
Expand Down Expand Up @@ -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 "<explicit list without voice>"`,
# 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"]
Expand Down
10 changes: 8 additions & 2 deletions src/core/all.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -202,7 +206,8 @@ fn build_registered_controllers() -> Vec<GroupedController> {
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,
Expand Down Expand Up @@ -632,7 +637,8 @@ fn build_registered_controllers() -> Vec<GroupedController> {
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,
Expand Down
33 changes: 33 additions & 0 deletions src/core/all_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions src/openhuman/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/openhuman/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
6 changes: 6 additions & 0 deletions src/openhuman/tools/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
49 changes: 49 additions & 0 deletions src/openhuman/voice/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<all-but-voice>"`): 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::*;
Loading
Loading