diff --git a/AGENTS.md b/AGENTS.md index 731e96b7f7..e9bc252853 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,6 +235,7 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | `voice` | ON | `openhuman::voice` + `openhuman::audio_toolkit` domains — STT/TTS providers, dictation server, always-on listening, podcast audio + email | `hound`, `lettre` | | `web3` | ON | `openhuman::wallet` + `openhuman::web3` + `openhuman::x402` domains — crypto wallet (multi-chain sign/broadcast), swaps/bridges/dapp calls, x402 machine payments | `bitcoin`, `curve25519-dalek` | | `media` | ON | `openhuman::media_generation` (the `media_generate_*` agent tools) + `openhuman::image` scaffold | none (surface-only) | +| `meet` | ON | `openhuman::meet` (join-URL validation) + `openhuman::meet_agent` (live STT/LLM/TTS loop) + `openhuman::agent_meetings` (backend-delegated Meet bot over Socket.IO) | none — see note | **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. @@ -243,6 +244,17 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ **`web3` gate — first gate that sheds real crypto deps.** Same facade pattern: `pub mod wallet;` / `pub mod web3;` / `pub mod x402;` stay always-compiled, real submodules are `#[cfg(feature = "web3")]`, and each domain's `stub.rs` re-exposes the always-on caller surface with disabled-error / empty bodies. When off, the wallet/web3/x402 controllers are unregistered, the web3 swap/bridge/dapp agent tools are absent (via `all_web3_agent_tools()` → empty), and the exclusive `bitcoin` (BTC P2WPKH PSBT) + `curve25519-dalek` (Solana off-curve ATA) deps are dropped. **tinyplace on-chain payments + Polymarket writes degrade to graceful "wallet disabled" errors** (the tinyplace comms path and the core itself are unaffected — `tinyplace::signer` still works via ed25519). The stubs cover `WALLET_NOT_CONFIGURED_MESSAGE`, `status`, `secret_material`, `WalletChain`, `prepare_transfer`/`execute_prepared` (+ param/result types), `solana_cluster`/`SolanaCluster`/`tinyplace_solana_rpc_endpoints`, `tinyplace_signer_seed`, `wallet::rpc::{redact_rpc_url, with_tinyplace_solana_endpoints}`, and the `all_*_registered_controllers`/`all_*_controller_schemas`/`all_web3_agent_tools` entry points. Two caller families still need per-call `#[cfg(feature = "web3")]` because they name concrete gated types rather than a stubbable aggregator: the six `Wallet*Tool` + `X402RequestTool` registrations in `tools/ops.rs`, the `wallet::tools::*` glob in `tools/mod.rs`, and the x402 402-retry path in `tools/impl/network/http_request.rs` (with the feature off a 402 returns to the caller unpaid). **Does NOT drop `ethers-core` / `ethers-signers` / `coins-bip39` / `bs58` / `ed25519-dalek` / `ripemd`** — those are shared with the Polymarket tools (`tools/impl/network/polymarket*`, `clob_auth`) + tinyplace + orchestration and stay always-on. Run the disabled build (`--no-default-features --features tokenjuice-treesitter`) before pushing any change to the wallet/web3/x402 surface — it is the only drift catcher. **Leaf-gate variant (`media`, #4804).** Unlike `voice`, the `media` gate needs **no** stub facade: `media_generation` has a single caller (the `build_media_tools` call in `src/openhuman/tools/ops.rs`, itself `#[cfg(feature = "media")]`) and `openhuman::image` is unwired scaffold (#2997), so both modules are simply `#[cfg(feature = "media")] pub mod …`. It is a **surface-only** gate: media generation is backend-proxied (`reqwest`, shared) and the `image` crate is shared with channel upload, so no exclusive deps are shed — the issue's "sheds media processing dependencies" / "controllers unregistered" DoD lines are superseded (Media is agent-tools-only; no controller/store/subscriber is tagged `Media`). When a gated domain is a true leaf, prefer this over the facade+stub. +**`meet` gate (#4800)** — uses all three module patterns, one per domain, chosen by the rule *"does always-compiled code reach a non-registration symbol in here?"*: + +- **`meet` → leaf-gate.** `#[cfg(feature = "meet")] pub mod meet;` outright. Its only outside reference is the registration site in `src/core/all.rs`, so no facade is needed (same shape as `audio_toolkit`). +- **`meet_agent` → facade + carve-out, no stub file.** Every submodule is gated **except `wav`** (below). Nothing outside the Meet domain calls the gated submodules. +- **`agent_meetings` → facade + stub.** Three always-compiled call sites reach in — the heartbeat planner (`calendar::handle_calendar_meeting_candidate`) and two subscriber registrations (`core::jsonrpc`, `channels::runtime::startup`) — so `src/openhuman/agent_meetings/stub.rs` supplies no-op equivalents and those callers need no `#[cfg]`. + +**No deps to shed (do not re-litigate).** Unlike `voice`, this gate drops **zero** dependencies — the Meet domains have no exclusive crates. `meet_agent::wav` is a hand-rolled 79-line RIFF writer with no `use` statements, written precisely so Meet never needed `hound` (which `voice` already owns and sheds). The dependency shed was pre-paid; this gate's value is compile-time surface and binary size, not the dep tree. + +**⚠ The `wav` carve-out is load-bearing.** `meet_agent::wav::pack_pcm16le_mono_wav` is called by `desktop_companion::pipeline::stt`, which is `DomainGroup::Platform` and compiled in **every** build. `pub mod wav;` must stay **ungated** so that call site keeps its real implementation — it costs nothing, since `wav.rs` is dependency-free. If someone gates it, the `--no-default-features` build fails loudly at `desktop_companion`; that failure is correct. Do **not** "fix" it by stubbing `pack_pcm16le_mono_wav` — that trades a compile error for green CI while silently corrupting desktop-companion STT (the STT backend would receive a malformed WAV). Revert the cfg instead. + +**Both-ways tests.** `src/core/all_tests.rs` pins the gate in both directions (`meet_controllers_registered_when_feature_on` / `meet_controllers_absent_when_feature_off`). The negative half is the one that proves the gate removes anything. Note CI's smoke lane runs `cargo check` only and never compiles test code, so a disabled-build **test** break is invisible to it — run `cargo test --lib --no-default-features --features tokenjuice-treesitter core::all::tests` locally after touching any gated surface. ### Event bus (`src/core/event_bus/`) diff --git a/Cargo.toml b/Cargo.toml index c5e73ffce2..ec5e3b80b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -341,7 +341,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice", "web3", "media"] +default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet"] # 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 = [ @@ -384,6 +384,20 @@ web3 = ["dep:bitcoin", "dep:curve25519-dalek"] # controllers / stores / subscribers tagged `Media` (agent tools only), and # `openhuman::image` is currently unwired scaffold (added #2997). media = [] +# Meet domains: `meet` (join-URL validation), `meet_agent` (live STT/LLM/TTS +# loop over an open call), and `agent_meetings` (backend-delegated Meet bot via +# Socket.IO). Default-ON — the desktop app always ships with Meet. Slim / +# headless builds opt out via `--no-default-features --features ""`. Composes with the runtime `DomainSet::meet` flag (#4796): the feature +# narrows the compile-time surface, `DomainSet` gates it at runtime. +# NOTE: unlike `voice`, this gate drops NO dependencies — the Meet domains have +# zero exclusive crates. `meet_agent::wav` is a hand-rolled RIFF writer +# precisely so Meet never needed `hound` (which `voice` already owns), so the +# dependency shed was pre-paid. The gate's value is compile-time surface + +# binary size, not the dep tree. +# CARVE-OUT: `meet_agent::wav` stays compiled in ALL builds — the always-on +# `desktop_companion` STT path depends on it. See `meet_agent/mod.rs`. +meet = [] sandbox-landlock = ["dep:landlock"] sandbox-bubblewrap = [] peripheral-rpi = ["dep:rppal"] diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 4fbdab7e8b..d3ce1e1ba0 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -159,6 +159,7 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal "media", "voice", "web3", + "meet", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/src/core/all.rs b/src/core/all.rs index e7697117be..eeeb04fda9 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -712,19 +712,25 @@ fn build_registered_controllers() -> Vec { DomainGroup::Platform, crate::openhuman::notifications::all_notifications_registered_controllers(), ); - // Google Meet call-join request validation (shell handles the webview) + // Google Meet call-join request validation (shell handles the webview). + // Gated behind the `meet` feature. + #[cfg(feature = "meet")] push( &mut controllers, DomainGroup::Meet, crate::openhuman::meet::all_meet_registered_controllers(), ); // Agent meetings — backend-delegated Meet bot via Socket.IO + // (gated with meet). + #[cfg(feature = "meet")] push( &mut controllers, DomainGroup::Meet, crate::openhuman::agent_meetings::all_agent_meetings_registered_controllers(), ); - // Live meet-agent loop: STT/LLM/TTS over the open call's audio. + // Live meet-agent loop: STT/LLM/TTS over the open call's audio + // (gated with meet). + #[cfg(feature = "meet")] push( &mut controllers, DomainGroup::Meet, diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index b8980b2f33..c5fbf6af43 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -909,7 +909,44 @@ fn group_mapping_smoke() { assert_eq!(group_for_namespace("voice"), Some(DomainGroup::Voice)); #[cfg(feature = "web3")] assert_eq!(group_for_namespace("wallet"), Some(DomainGroup::Web3)); + // `meet` is compiled out under `--no-default-features`, so the registry has + // no entry to map (#4800). + #[cfg(feature = "meet")] assert_eq!(group_for_namespace("meet"), Some(DomainGroup::Meet)); // Internal-only registry is grouped too (mcp_audit → Mcp). assert_eq!(group_for_namespace("mcp_audit"), Some(DomainGroup::Mcp)); } + +/// All three Meet namespaces register when the `meet` feature is on (#4800). +/// +/// Paired with `meet_controllers_absent_when_feature_off` below: together they +/// pin *both* directions of the compile-time gate. The negative half is the one +/// that actually proves the gate does something — a gate that never removes +/// anything would still pass this positive test. +#[cfg(feature = "meet")] +#[test] +fn meet_controllers_registered_when_feature_on() { + for ns in ["meet", "agent_meetings", "meet_agent"] { + assert_eq!( + group_for_namespace(ns), + Some(DomainGroup::Meet), + "`{ns}` must register under DomainGroup::Meet when the `meet` feature is on" + ); + } +} + +/// No Meet namespace registers when the `meet` feature is off (#4800). +/// +/// This is the half that proves the gate: with `meet` compiled out the three +/// domains must leave zero trace in either the public or the internal registry. +#[cfg(not(feature = "meet"))] +#[test] +fn meet_controllers_absent_when_feature_off() { + for ns in ["meet", "agent_meetings", "meet_agent"] { + assert_eq!( + group_for_namespace(ns), + None, + "`{ns}` must not register when the `meet` feature is off" + ); + } +} diff --git a/src/openhuman/agent_meetings/mod.rs b/src/openhuman/agent_meetings/mod.rs index ef2c892494..7539e3b4d9 100644 --- a/src/openhuman/agent_meetings/mod.rs +++ b/src/openhuman/agent_meetings/mod.rs @@ -13,18 +13,43 @@ //! - [`schemas`] — controller schema + registered handler wrappers //! - [`store`] — SQLite persistence for meeting sessions //! - [`in_call`] — Phase 2 in-call agency: wake-phrase command → orchestrator → `bot:speak` +//! +//! ## Compile-time gating (`meet` feature, #4800) +//! +//! All submodules are `#[cfg(feature = "meet")]`. Three always-compiled call +//! sites reach in here for non-registration symbols — the heartbeat planner +//! (`calendar::handle_calendar_meeting_candidate`) plus two subscriber +//! registrations (`core::jsonrpc`, `channels::runtime::startup`) — so a +//! `#[cfg(not(feature = "meet"))]` [`stub`] supplies no-op equivalents and +//! those callers need no cfg of their own. +#[cfg(feature = "meet")] pub mod bus; +#[cfg(feature = "meet")] pub mod calendar; +#[cfg(feature = "meet")] pub mod in_call; +#[cfg(feature = "meet")] pub mod ops; +#[cfg(feature = "meet")] pub mod recent_calls; +#[cfg(feature = "meet")] pub mod schemas; +#[cfg(feature = "meet")] pub mod store; +#[cfg(feature = "meet")] pub mod summary; +#[cfg(feature = "meet")] pub mod types; +#[cfg(feature = "meet")] pub mod upcoming; +#[cfg(not(feature = "meet"))] +mod stub; +#[cfg(not(feature = "meet"))] +pub use stub::*; + +#[cfg(feature = "meet")] pub use schemas::{ all_controller_schemas as all_agent_meetings_controller_schemas, all_registered_controllers as all_agent_meetings_registered_controllers, diff --git a/src/openhuman/agent_meetings/stub.rs b/src/openhuman/agent_meetings/stub.rs new file mode 100644 index 0000000000..ecbf3b339f --- /dev/null +++ b/src/openhuman/agent_meetings/stub.rs @@ -0,0 +1,46 @@ +//! No-op stand-ins for the `agent_meetings` symbols that always-compiled code +//! reaches for, used when the `meet` feature is off (#4800). +//! +//! Only the three symbols with non-registration call sites outside the Meet +//! domain live here. Everything else in `agent_meetings` is reachable only via +//! the controller registry, which is itself `#[cfg(feature = "meet")]` at +//! `core::all` — so it needs no stub. +//! +//! These are genuine no-ops, not "register a controller that then errors": a +//! `register_*` that registers nothing is exactly the intended disabled-build +//! behaviour — the subscriber simply never exists, so the events never route. + +/// Stub of [`super::calendar`](../calendar/index.html) for `--no-default-features` builds. +pub mod calendar { + /// No-op stand-in for `calendar::register_meet_calendar_subscriber`. + /// + /// With `meet` compiled out there is no calendar-triggered meeting flow to + /// drive, so registering nothing is the correct behaviour: the event bus + /// keeps routing, it just has no Meet subscriber attached. + pub fn register_meet_calendar_subscriber() {} + + /// No-op stand-in for `calendar::handle_calendar_meeting_candidate`. + /// + /// The `bool` means "Meet published its own actionable card, so the caller + /// should skip its generic one". With `meet` compiled out no card was ever + /// published, so `false` is semantically exact — the heartbeat planner + /// correctly falls through to its plain "meeting starting" reminder. + pub async fn handle_calendar_meeting_candidate( + _meet_url: String, + _event_title: String, + _owner_display_name: Option, + _calendar_event_id: Option, + ) -> bool { + false + } +} + +/// Stub of [`super::bus`](../bus/index.html) for `--no-default-features` builds. +pub mod bus { + /// No-op stand-in for `bus::register_meeting_event_subscriber`. + /// + /// Same reasoning as `calendar::register_meet_calendar_subscriber`: the + /// `Meeting*` / `BackendMeet*` domain events still exist on the bus (they + /// are inert plain data), they simply have no subscriber and never fire. + pub fn register_meeting_event_subscriber() {} +} diff --git a/src/openhuman/meet_agent/mod.rs b/src/openhuman/meet_agent/mod.rs index 4207171bf5..1d83815b8a 100644 --- a/src/openhuman/meet_agent/mod.rs +++ b/src/openhuman/meet_agent/mod.rs @@ -33,19 +33,57 @@ //! - `openhuman.meet_agent_push_listen_pcm` — shell pushes captured PCM frames //! - `openhuman.meet_agent_poll_speech` — shell pulls synthesized PCM frames //! - `openhuman.meet_agent_stop_session` — close session, flush pending audio +//! +//! ## Compile-time gating (`meet` feature, #4800) +//! +//! Every submodule here is `#[cfg(feature = "meet")]` — **except [`wav`]**. +//! +//! ### ⚠ The `wav` carve-out is load-bearing — do not "tidy" it +//! +//! [`wav::pack_pcm16le_mono_wav`] is called by +//! `desktop_companion::pipeline::stt`, which is `DomainGroup::Platform` and is +//! therefore compiled in **every** build, including `--no-default-features`. +//! `wav` must stay ungated so that call site keeps its **real** implementation. +//! +//! This is safe to leave ungated at zero cost: `wav.rs` is a self-contained +//! hand-rolled RIFF writer with no `use` statements and no dependencies. It +//! pulls in nothing when the rest of the domain is compiled out. (It is +//! hand-rolled precisely so Meet never needed `hound`, which the `voice` gate +//! already owns and sheds.) +//! +//! **If you ever add `#[cfg(feature = "meet")]` to `pub mod wav;`**, the +//! `--no-default-features` build will fail loudly at `desktop_companion`. That +//! failure is *correct and useful*. Do **not** "fix" it by stubbing +//! `pack_pcm16le_mono_wav` to return an empty/placeholder buffer: that turns a +//! compile error into green CI while silently corrupting desktop-companion STT +//! forever (the STT backend would receive a malformed WAV). Revert the cfg +//! instead — or, if `wav` genuinely must move, relocate it to a always-compiled +//! home rather than stubbing it. +#[cfg(feature = "meet")] pub mod brain; +#[cfg(feature = "meet")] pub mod ops; +#[cfg(feature = "meet")] pub mod rpc; +#[cfg(feature = "meet")] pub mod schemas; +#[cfg(feature = "meet")] pub mod session; +#[cfg(feature = "meet")] pub mod store; +#[cfg(feature = "meet")] pub mod types; +// NOT gated — see the carve-out note above. `desktop_companion` (always-on) +// depends on the real implementation. pub mod wav; +#[cfg(feature = "meet")] pub use schemas::{ all_controller_schemas as all_meet_agent_controller_schemas, all_registered_controllers as all_meet_agent_registered_controllers, }; +#[cfg(feature = "meet")] pub use session::{MeetAgentSession, MeetAgentSessionRegistry, SESSION_REGISTRY}; +#[cfg(feature = "meet")] pub use types::*; diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 601a569cfc..561581b40c 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -71,6 +71,7 @@ pub mod mcp_registry; pub mod mcp_server; #[cfg(feature = "media")] pub mod media_generation; +#[cfg(feature = "meet")] pub mod meet; pub mod meet_agent; pub mod memory;