diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index e1e4b94a2a..bf00872571 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -565,10 +565,13 @@ jobs: # name, `tinymemory-bus`. 265 -> 267 on 2026-08-22: the MCP extraction # adds `tinymcp` and `tinymcp-bus`. 267 -> 268 on 2026-08-22: language # runtimes moved behind the `tinyruntime` TinyBus module, adding - # `tinyruntime-bus`. See the kernel-floor history for why both raises are - # temporary/justified. macOS resolves one higher per the host skew - # recorded in the limits history — this expects the CI host. - run: python3 scripts/dep-sim.py --cut-nothing --expect-names 268 + # `tinyruntime-bus`. 268 -> 269 on 2026-08-23: the TinyJuice wire + # contract moved into `tinyjuice-bus`, which cannot be gated because + # `inference::tokenjuice` compiles in every build. See the kernel-floor + # history for why these raises are temporary/justified. macOS resolves + # one higher per the host skew recorded in the limits history — this + # expects the CI host. + run: python3 scripts/dep-sim.py --cut-nothing --expect-names 269 - name: Guard — new feature-gated test modules must be acknowledged # Self-maintaining coverage: the set of source files that #[cfg]-gate a test on diff --git a/.gitmodules b/.gitmodules index 82b93c915e..a7c232daf1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -41,3 +41,15 @@ [submodule "vendor/tinyruntime"] path = vendor/tinyruntime url = https://github.com/tinyhumansai/tinyruntime.git +[submodule "vendor/tinydocs"] + path = vendor/tinydocs + url = https://github.com/tinyhumansai/tinydocs + branch = main +[submodule "vendor/tinyvoice"] + path = vendor/tinyvoice + url = https://github.com/tinyhumansai/tinyvoice + branch = main +[submodule "vendor/tinyjuice"] + path = vendor/tinyjuice + url = https://github.com/tinyhumansai/tinyjuice + branch = main diff --git a/AGENTS.md b/AGENTS.md index 92c232b26d..53236578be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,46 +200,81 @@ Audit new Tauri plugins for `js_init_script` calls. ## Rust core (`src/`) -### Extracted host-agnostic crates — `vendor/tinydocs`, `vendor/tinywallet` +### Module wire contracts — one `*-bus` crate per loadable module -Two vendored crates own logic that used to live in this repo. Both are git -submodules consumed by `path` (not published to crates.io, so no -`[patch.crates-io]` entry — same shape as `tinyhumans-sdk`). After cloning: -`git submodule update --init vendor/tinydocs vendor/tinywallet`. +A capability that runs in a loaded module is reached over the bus, and a host +cannot import Rust items from a `cdylib`. So every module ships an ordinary +crate carrying its **call vocabulary** — interface names, member names, request +and response types, and the contract version — and this crate links that and +nothing else from the module's repository. Each is a git submodule consumed by +`path` (not published to crates.io, so no `[patch.crates-io]` entry — same shape +as `tinyhumans-sdk`). + +| Contract crate | Gate | Reached from | +| --- | --- | --- | +| `tinydocs-bus` | `documents` | `modules/documents.rs`, `tools/impl/document/` (as `format`) | +| `tinyvoice-bus` | `voice` | `modules/voice.rs` | +| `tinyjuice-bus` | **none** — `inference::tokenjuice` is kernel | `inference/tokenjuice/types.rs`, `modules/tokenjuice_host.rs` | +| `tinyruntime-bus` | none — `ShellTool` holds an `Option>` field | `modules/runtime.rs`, `runtime/**` | +| `tinywallet-bus` | `web3` | `modules/wallet.rs`, `web3/**` | +| `tinymcp-bus` | `mcp` | `mcp/**` | + +After cloning: `git submodule update --init --recursive vendor/`. **What this binary takes from each repository is its `-bus` contract crate, not -its root crate.** A `-bus` crate is transport-free and holds the interface name, -the object path, one constant per member, the payload types and the contract -version — plus the pure rules a host genuinely runs itself. The root crate holds -the implementation the TinyBus module carries, and this binary does not link it. -`vendor/tinywallet/crates/tinywallet-bus` is the entry here; the same shape as -`tinyvoice-bus`, `tinyjuice-bus`, `tinyruntime-bus` and `tinydocs-bus`. +its root crate.** The root crate holds the implementation the TinyBus module +carries, and this binary does not link it. `tinymcp` is the one exception, and a +temporary one: its path dependency stays until `tinymcp-bus` grows the members +the host reaches for (see the `Cargo.toml` comment and tinyhumansai/tinymcp#4). + +**Never re-declare a contract type here.** Each of these crates replaced a copy +that had already drifted or was one edit away from it — `tools/impl/document/ +format/` was 1,873 lines differing from `crates/tinydocs-bus/src/` only in +doc-link paths, `modules/voice.rs` redeclared four types with a comment +explaining that it had to, and `inference/tokenjuice/types.rs` was 259 lines +headed "shared with the separately compiled module" and shared by convention +alone. A field added on one side of a copy is a decode failure on the other with +nothing to catch it, and for the document specs it is worse than that: those +specs are also what an LLM is shown as a JSON tool schema, so a limit that moves +upstream becomes a tool description promising what the module does not enforce. + +**Call members by their constant, never by a string.** `methods::GENERATE_DOCX`, +not `"GenerateDocx"`. A rename upstream is then a compile error here instead of +a `MemberNotFound` at runtime. + +**`registry.rs` is the one place a name is still written out by hand.** It is a +`const` table and cannot name a gated crate, so the `_tests.rs` beside each +module client assert its `bus_name` / `object_path` against the contract's +`BUS_NAME` / `OBJECT_PATH`. A mismatch is not a compile error — it is a +`NameHasNoOwner` at first use, in the field, on whichever platform nobody tested. + +**Host policy stays host-side.** The contract says what a module may send; it +does not decide what this host will act on. When a type becomes foreign, the +policy attached to it becomes a free function rather than moving upstream — +`modules/voice.rs`'s `clamped` (a volume that reaches an `osascript` command), +`vad_config_from_server_config` (this host persists seconds, the module speaks +milliseconds), and `hallucination_mode_wire`. The split follows one rule, and it is worth stating because it decides where the *next* extraction goes: **a crate owns what is the same for every host; the -host owns what depends on its own runtime, config, or threat model.** Both -crates are therefore synchronous, I/O-free, and runtime-free. +host owns what depends on its own runtime, config, or threat model.** The +contract crates are therefore synchronous, I/O-free, and runtime-free. | Crate | Owns | OpenHuman keeps | | --- | --- | --- | -| `tinydocs` | the `.docx` spec types, their size limits, validation, and OOXML synthesis (`docx-rs` sits behind it) | the artifact pipeline, the `spawn_blocking` hop, and the generation deadline — `src/openhuman/tools/impl/document/` | +| `tinydocs-bus` | the `.docx` / `.pptx` spec types, their size limits and validation | the artifact pipeline, the `spawn_blocking` hop, and the generation deadline — `src/openhuman/tools/impl/document/` | | `tinywallet-bus` | the TinyWallet wire contract and bus member names, the BTC / EVM / Solana / Tron address formats, the EIP-712 and ERC-20 encoders, and the Tron verification codec | RPC endpoint resolution, transaction assembly and broadcast, key custody — `src/openhuman/web3/` | Consequences worth knowing before touching either seam: -- **`tinydocs::docx::generate` is synchronous on purpose.** A crate that - guessed at an executor or a deadline would be wrong for every host that - guessed differently, so `document/engine.rs` supplies exactly that policy and - nothing else. `DocumentError::GenerationTimeout` therefore has no `tinydocs` - equivalent and can only be produced host-side. -- **`tinydocs::Error` is `#[non_exhaustive]`.** The `From` impl in - `document/types.rs` needs its catch-all arm; it degrades an unmapped variant - to `GenerationFailed` and logs, so a crate bump that adds a case worth - handling structurally shows up rather than being swallowed. -- **The JSON tool schema did not change.** `GenerateDocumentInput` is - `tinydocs`' `DocumentSpec` re-exported under its historical name, with field - names unchanged; `the_json_wire_shape_is_unchanged_by_the_extraction` pins - that. +- **A `-bus` crate may hold logic, not only types, and that is deliberate.** + Four wallet rules are the host's to run synchronously: validating an address + before a spec is sent (a rejected input rather than a failed call), hashing + EIP-712 typed data for the x402 payment path, encoding ERC-20 calldata, and + verifying the txid and contents of what a Tron node handed back. That last one + is not optional — Tron has the *node* build the transaction, so the check has + to happen wherever the decision to sign is made. `tinydocs-bus`' spec + validators set the same precedent. - **`tinywallet-bus` rejects an uppercase `0X` EVM prefix, matching the code it replaced, which rejected that prefix too.** The old path went through `ethers_core::types::Address`'s `FromStr`, which is `fixed-hash`'s and strips only a lowercase `0x` @@ -249,31 +284,27 @@ Consequences worth knowing before touching either seam: - **Bitcoin has two rules, not one.** `btc::validate` is the recipient rule; `btc::validate_sender` additionally requires P2WPKH. Using the first where the second belongs accepts an address that only fails later, at signing time. -- **`tinywallet-bus` holds logic, not only types, and that is deliberate.** Four - rules are the host's to run synchronously: validating an address before a spec - is sent (a rejected input rather than a failed call), hashing EIP-712 typed - data for the x402 payment path, encoding ERC-20 calldata, and verifying the - txid and contents of what a Tron node handed back. That last one is not - optional — Tron has the *node* build the transaction, so the check has to - happen wherever the decision to sign is made. Same precedent `tinydocs`' - spec validators set: a bus crate carrying host-side rules is established here, - not a novelty. -- **Member names come from `tinywallet_bus::names::methods`, never a literal.** - `src/openhuman/modules/wallet.rs` calls by constant, and - `wallet_tests.rs`'s `contract` module pins `registry.rs`'s `bus_name` / - `object_path` against `BUS_NAME` / `OBJECT_PATH` and every member it sends - against `METHODS` + `CONFIDENTIAL_METHODS`. The registry is a compiled-in - `const` table that cannot name a gated crate, so a drifted string is a - `NameHasNoOwner` in the field rather than a compile error. - **The root `tinywallet` crate survives as a dev-dependency only.** Test fixtures derive a known account through its `key` gate. Cargo does not link dev-dependency features into the shipped binary, so this does not put `bitcoin`, `coins-bip39` or a native `secp256k1` build back into the product. -- **Each crate's gates ride OpenHuman's existing ones**: the tinydocs entry is - exclusive to `documents`, `tinywallet-bus` to `web3`. Both are default-ON and - already forwarded to the desktop shell. Both are taken with - `default-features = false` — the wire contract, not the implementation, which - runs in the TinyBus module instead (see the module host section). +- **Document generation is synchronous on purpose.** A crate that guessed at an + executor or a deadline would be wrong for every host that guessed + differently, so `document/engine.rs` supplies exactly that policy and nothing + else. `DocumentError::GenerationTimeout` therefore has no contract equivalent + and can only be produced host-side. +- **`tinydocs_bus::Error` is `#[non_exhaustive]`.** The `From` impl in + `document/types.rs` needs its catch-all arm; it degrades an unmapped variant + to `GenerationFailed` and logs, so a crate bump that adds a case worth + handling structurally shows up rather than being swallowed. +- **The JSON tool schema did not change.** `GenerateDocumentInput` is the + contract's `DocumentSpec` re-exported under its historical name, with field + names unchanged; `the_json_wire_shape_is_unchanged_by_the_extraction` pins + that. +- **Each crate's gates ride OpenHuman's existing ones**: `tinydocs-bus` is + exclusive to `documents`, `tinywallet-bus` to `web3`. Both are default-OFF for + contributors and product-ON, and both are already forwarded to the desktop + shell. ### Backend API access — `src/api/` over `tinyhumans-sdk` @@ -639,7 +670,7 @@ Two columns because there are two sets (see above): **Contrib** is `[features] d | `inference` | OFF | ON | the `cpal` audio-device stack: microphone capture for voice, plus `desktop::accessibility::permissions`' mic-permission probe. Implied by `voice`. Off ⇒ the probe reports `Unknown`. **The name is historical** — it used to gate the bundled whisper.cpp STT engine, which no longer exists (see the scope note below); do not rename it, it is forwarded by name from the shell manifest and asserted by `INFERENCE_COMPILED_IN` | `cpal` | | `web3` | OFF | ON | the `openhuman::web3` family (`web3`, `web3::wallet`, `web3::x402`) — crypto wallet (multi-chain sign/broadcast), swaps/bridges/dapp calls, x402 machine payments | `bitcoin`, `curve25519-dalek` | | `media` | ON | ON | `openhuman::media::generation` (the `media_generate_*` agent tools) + `openhuman::media::image` scaffold | none (surface-only) | -| `documents` | OFF | ON | the `generate_document` / `generate_presentation` agent tools and PDF text extraction during multimodal ingest. **The synthesis is not in this build** — all three run in the `tinydocs` TinyBus module (see below), so this gate turns on the tools and the host policy around them: the artifact pipeline, the deadlines, image resolution under the security policy. `tinydocs` is consumed with `default-features = false`, for the wire contract only. Implies `modules`. Off ⇒ both tools absent from the tool list rather than degraded, and PDF ingest degrades a file to a reference instead of extracted text | **39 crates**, and they leave `Cargo.lock` entirely: `docx-rs`, `ppt-rs`, `pdf-extract` plus `lopdf`, `syntect`, `pulldown-cmark`, `xml-rs`, `quick-xml`, `zip 0.6`, `zstd`, `bzip2`, `encoding_rs`, `euclid`, `ttf-parser`, the CFF/Type1/CMap parsers, … Product profile 505 → 448 names | +| `documents` | OFF | ON | the `generate_document` / `generate_presentation` agent tools and PDF text extraction during multimodal ingest. **The synthesis is not in this build** — all three run in the `tinydocs` TinyBus module (see below), so this gate turns on the tools and the host policy around them: the artifact pipeline, the deadlines, image resolution under the security policy. The dependency is `tinydocs-bus`, the wire contract crate, and nothing else from that repository. Implies `modules`. Off ⇒ both tools absent from the tool list rather than degraded, and PDF ingest degrades a file to a reference instead of extracted text | **39 crates**, and they leave `Cargo.lock` entirely: `docx-rs`, `ppt-rs`, `pdf-extract` plus `lopdf`, `syntect`, `pulldown-cmark`, `xml-rs`, `quick-xml`, `zip 0.6`, `zstd`, `bzip2`, `encoding_rs`, `euclid`, `ttf-parser`, the CFF/Type1/CMap parsers, … Product profile 505 → 448 names | | `modules` | ON | ON | `openhuman::modules` — the dynamic module host: the loader that admits a compiled `cdylib` through tinybus's ABI descriptor, manifest, dependency and SHA-256 gates, the compiled-in registry of modules this build trusts, and the `modules` RPC namespace. Implied by `documents`. Off ⇒ `modules.*` is unknown-method and nothing can load a native module | none in the product profile (`ureq`, `flate2`, `tar`, `zip 2`, `tempfile`, `toml` are already there) — **but see the kernel-floor note**: this feature exists so `tinybus/modules` is not enabled on the dependency itself, which would put a `dlopen` loader into the kernel profile where `tinybus` is always-on | | `skills` | ON | ON | `openhuman::skills` + `openhuman::skills::runtime` + `openhuman::skills::catalog` domains — SKILL.md discovery/parse/install, workflow execution + run logs, remote catalogs, the `skill_setup` / `skill_executor` builtin agents, and the 16 skill agent tools | none (see below) | | `flows` | ON | ON | `openhuman::flows` (saved automation graphs — create/run/schedule, the `workflow_builder` + `flow_discovery` agents), `openhuman::flows::tinyflows` (engine seam), `openhuman::flows::rhai` (`.ragsh` language-workflow tool) | `tinyflows`, `jaq-core`, `jaq-std`, `jaq-json`, `rhai` | diff --git a/Cargo.lock b/Cargo.lock index 9b1b94b0a6..f3ca3ad63d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4181,9 +4181,11 @@ dependencies = [ "tinychannels", "tinycortex", "tinycortex-api", + "tinydocs-bus", "tinyflows", "tinyhosts", "tinyhumans-sdk", + "tinyjuice-bus", "tinymcp", "tinymcp-bus", "tinymemory", @@ -4192,6 +4194,7 @@ dependencies = [ "tinymemory-tinycortex", "tinyplace", "tinyruntime-bus", + "tinyvoice-bus", "tinywallet", "tinywallet-bus", "tokio", @@ -6455,6 +6458,14 @@ dependencies = [ "tinymemory-api", ] +[[package]] +name = "tinydocs-bus" +version = "0.1.14" +dependencies = [ + "serde", + "thiserror 2.0.18", +] + [[package]] name = "tinyflows" version = "0.8.0" @@ -6503,6 +6514,13 @@ dependencies = [ "url", ] +[[package]] +name = "tinyjuice-bus" +version = "0.2.4" +dependencies = [ + "serde", +] + [[package]] name = "tinymcp" version = "0.3.1" @@ -6681,7 +6699,7 @@ dependencies = [ [[package]] name = "tinyruntime-bus" -version = "0.2.1" +version = "0.2.2" dependencies = [ "serde", "serde_json", @@ -6721,6 +6739,13 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tinyvoice-bus" +version = "0.1.2" +dependencies = [ + "serde", +] + [[package]] name = "tinywallet" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index 7b33d9bf3f..3720cdba6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -489,24 +489,67 @@ crossterm = { version = "0.29", optional = true } unicode-width = { version = "0.2", optional = true } # TinyDocs — the document wire contract, and nothing else. # -# `default-features = false` is load-bearing: it takes the spec types, their -# size limits, the validation rules and the PNG/JPEG header reader, and leaves -# every writer behind. Synthesis happens in the `tinydocs` TinyBus module +# `tinydocs-bus` is the whole dependency: the spec types, their size limits, +# the validation rules, the PNG/JPEG header reader, the error vocabulary and +# the member names. It is `serde` + `thiserror` and nothing else — no writer, +# no `tinybus`, no runtime. Synthesis happens in the `tinydocs` TinyBus module # (`src/openhuman/modules/`), so this build carries the shape of a document -# without carrying the code that produces one — which is why `docx-rs`, +# without carrying the code that produces one, which is why `docx-rs`, # `ppt-rs` and `pdf-extract` are absent from the graph entirely rather than # merely gated. # # Sharing the contract rather than re-declaring it is the point. The specs are # what an LLM is shown as a JSON tool schema and what the module validates # against; two definitions of that would drift, and the drift would be a tool -# description promising limits the module does not enforce. +# description promising limits the module does not enforce. This crate carried +# a verbatim copy of the contract for exactly that reason and paid exactly that +# risk — `src/openhuman/tools/impl/document/format/` was 1,873 lines that +# differed from `crates/tinydocs-bus/src/` only in doc-link paths. # # Vendored as a path dependency like `tinyhumans-sdk`: the crate is not # published to crates.io, so there is no `[patch.crates-io]` entry for it. # After cloning: `git submodule update --init vendor/tinydocs`. # -# Optional: exclusive to the default-ON `documents` feature. +# Optional: exclusive to the `documents` feature, which is **default-OFF, +# product-ON** — `[features] default` is the contributor set and this is not in +# it; `scripts/ci/product-features.txt` is what the shipped desktop app has, and +# it is. The comment here said "default-ON", which was true before #4919 split +# the two sets and has been wrong since. +tinydocs-bus = { path = "vendor/tinydocs/crates/tinydocs-bus", optional = true } + +# TinyVoice — the voice wire contract, and nothing else. +# +# Same arrangement as `tinydocs-bus`: member names, the payload types the +# module answers with, and the contract version, at a cost of `serde`. The +# processing lives in the `tinyvoice` TinyBus module, so nothing here decodes +# audio. `src/openhuman/modules/voice.rs` used to redeclare these types with a +# comment saying it did so because this crate did not depend on TinyVoice; +# it does now. +# +# After cloning: `git submodule update --init vendor/tinyvoice`. +# +# Optional: exclusive to the `voice` feature — default-OFF, product-ON, the +# same split as `documents` above. +tinyvoice-bus = { path = "vendor/tinyvoice/crates/tinyvoice-bus", optional = true } + +# TinyJuice — the compression wire contract, and nothing else. +# +# Same arrangement as `tinydocs-bus` and `tinyvoice-bus`: the payload types, the +# request and response envelopes, the member names and the contract version, at +# a cost of `serde`. The router, the compressors, the CCR cache and the rule +# engine all live in the `tinyjuice` module and are not in this build. +# +# `src/openhuman/inference/tokenjuice/types.rs` was a 259-line hand-copy of +# these types headed "Stable wire types shared with the separately compiled +# TinyJuice module" — shared by convention and checked by nobody. It is a +# re-export now. +# +# Not optional: `inference::tokenjuice` is always compiled (the compression +# middleware sits in the agent turn path, which is kernel), so the contract has +# no gate to hang off. +# +# After cloning: `git submodule update --init vendor/tinyjuice`. +tinyjuice-bus = { path = "vendor/tinyjuice/crates/tinyjuice-bus" } # TinyHosts — the unified hosting API: one `Host` trait over a hosting provider, # and the `launch` flow that puts a Next.js application, its database, its @@ -775,7 +818,7 @@ inference = ["dep:cpal"] # reference instead of extracted text # (`agent::multimodal::extract_pdf_text`). Slim / headless builds opt out via # `--no-default-features --features ""`. -documents = ["modules"] +documents = ["modules", "dep:tinydocs-bus"] # Hosting: the `hosting_*` agent tools that put a workspace on a real hosting # provider — a site, a managed database wired into it, its environment, its # domains, its deployments and the traffic they served. Default-OFF, @@ -830,6 +873,9 @@ voice = [ # alone fails to resolve `openhuman::modules` — which the product build # hides, because `documents` turns `modules` on anyway. "modules", + # The member names and payload types the module answers with. Contract + # only — `serde` and nothing else. + "dep:tinyvoice-bus", "dep:lettre", "dep:arboard", "dep:enigo", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 55cbf6b373..24382095c5 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4420,9 +4420,11 @@ dependencies = [ "tinychannels", "tinycortex", "tinycortex-api", + "tinydocs-bus", "tinyflows", "tinyhosts", "tinyhumans-sdk", + "tinyjuice-bus", "tinymcp", "tinymcp-bus", "tinymemory", @@ -4431,6 +4433,7 @@ dependencies = [ "tinymemory-tinycortex", "tinyplace", "tinyruntime-bus", + "tinyvoice-bus", "tinywallet-bus", "tokio", "tokio-stream", @@ -7175,6 +7178,14 @@ dependencies = [ "tinymemory-api", ] +[[package]] +name = "tinydocs-bus" +version = "0.1.14" +dependencies = [ + "serde", + "thiserror 2.0.20", +] + [[package]] name = "tinyflows" version = "0.8.0" @@ -7223,6 +7234,13 @@ dependencies = [ "url", ] +[[package]] +name = "tinyjuice-bus" +version = "0.2.4" +dependencies = [ + "serde", +] + [[package]] name = "tinymcp" version = "0.3.1" @@ -7401,7 +7419,7 @@ dependencies = [ [[package]] name = "tinyruntime-bus" -version = "0.2.1" +version = "0.2.2" dependencies = [ "serde", "serde_json", @@ -7432,6 +7450,13 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tinyvoice-bus" +version = "0.1.2" +dependencies = [ + "serde", +] + [[package]] name = "tinywallet-bus" version = "0.5.0" diff --git a/scripts/kernel-floor.limits b/scripts/kernel-floor.limits index 330fac6e53..c81a6d4272 100644 --- a/scripts/kernel-floor.limits +++ b/scripts/kernel-floor.limits @@ -13,6 +13,32 @@ # Simulate with: scripts/dep-sim.py --cut # # History +# 287/269/2 2026-08-23 the TinyJuice wire contract moved into its own crate +# (+1 package, +1 NAME: `tinyjuice-bus`). This entry is +# the CURRENT limit; the tinymcp entry below it left the +# profile at 286/268/2 and this raises it by one on top of +# that. +# +# It is `serde` and +# nothing else — no transport, no runtime, no native code — +# and it brings no third-party crate this profile did not +# already have. +# +# It cannot be gated: `inference::tokenjuice` compiles in +# every build because the compression middleware sits in the +# agent turn path, so the contract has no feature to hang +# off. The two other contracts that landed in the same PR +# DO have one and cost this profile nothing — +# `tinydocs-bus` is exclusive to `documents` and +# `tinyvoice-bus` to `voice`, both default-OFF for +# contributors. +# +# What it buys is the deletion of 259 lines of hand-copied +# wire types from `inference/tokenjuice/types.rs`, which +# were a second definition of a contract with nothing +# checking that the two agreed. A crate in the graph is the +# cheaper of the two failure modes. +# # 286/268/2 2026-08-23 tinymcp registry entry, NO CHANGE to this profile — # and the entry exists to correct the prediction in the # 2026-08-22 line below, which is measurably wrong. @@ -434,4 +460,4 @@ # (libsqlite3-sys, ring) — see docs/plans MIGRATION-PLAN G6. # 307/284 2026-08-12 Re-baseline after the upstream lockfile resolution; # `flows` remains at two native packages. -flows:286:268:2 +flows:287:269:2 diff --git a/src/openhuman/inference/tokenjuice/mod.rs b/src/openhuman/inference/tokenjuice/mod.rs index 36f6fac3b6..76b915a13d 100644 --- a/src/openhuman/inference/tokenjuice/mod.rs +++ b/src/openhuman/inference/tokenjuice/mod.rs @@ -7,11 +7,13 @@ pub mod schemas; pub mod tools; pub mod types; -use serde::Serialize; +use tinyjuice_bus::names::methods; pub use tools::TokenjuiceRetrieveTool; pub use types::{AgentTokenjuiceCompression, CompressorKind, ContentKind}; +use types::InstallRequest; + pub const RETRIEVE_TOOL_NAME: &str = "tinyjuice_retrieve"; pub const LEGACY_RETRIEVE_TOOL_NAME: &str = "retrieve_tool_output"; pub const RECOVERY_TOOL_NAMES: &[&str] = &[ @@ -24,16 +26,6 @@ pub fn is_recovery_tool(name: &str) -> bool { RECOVERY_TOOL_NAMES.contains(&name) } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct InstallRequest { - options: types::CompressOptions, - max_cache_entries: usize, - max_cache_bytes: usize, - ccr_ttl_secs: Option, - disk_tier_root: Option, -} - pub async fn install_from_config(config: &crate::openhuman::config::Config) -> Result<(), String> { let tj = &config.tokenjuice; ml::configure(config.clone()); @@ -76,7 +68,7 @@ pub async fn install_from_config(config: &crate::openhuman::config::Config) -> R } proxy(config) .await? - .call::<()>("Install", (request,)) + .call::<()>(methods::INSTALL, (request,)) .await .map_err(|e| e.to_string())?; *installed = Some(fingerprint); @@ -161,7 +153,7 @@ pub async fn compact_output_with_policy( }; let response: types::CompactResponse = match proxy .call( - "Compact", + methods::COMPACT, (content.clone(), tool_name.to_string(), enabled, profile), ) .await @@ -194,7 +186,7 @@ pub async fn detect(content: String, hint: types::ContentHint) -> Result Result { install_from_config(&config).await?; proxy(&config) .await? - .call("CacheStats", ()) + .call(methods::CACHE_STATS, ()) .await .map_err(|error| error.to_string()) } diff --git a/src/openhuman/inference/tokenjuice/types.rs b/src/openhuman/inference/tokenjuice/types.rs index 89b143731f..8ccbcbabec 100644 --- a/src/openhuman/inference/tokenjuice/types.rs +++ b/src/openhuman/inference/tokenjuice/types.rs @@ -1,214 +1,27 @@ -//! Stable wire types shared with the separately compiled TinyJuice module. - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum AgentTokenjuiceCompression { - #[default] - Auto, - Full, - Light, - Off, -} - -impl AgentTokenjuiceCompression { - pub fn as_str(self) -> &'static str { - match self { - Self::Auto => "auto", - Self::Full => "full", - Self::Light => "light", - Self::Off => "off", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ContentKind { - Json, - Code, - Log, - Search, - Diff, - Html, - PlainText, -} - -impl ContentKind { - pub fn as_str(self) -> &'static str { - match self { - Self::Json => "json", - Self::Code => "code", - Self::Log => "log", - Self::Search => "search", - Self::Diff => "diff", - Self::Html => "html", - Self::PlainText => "plain_text", - } - } -} - -impl std::str::FromStr for ContentKind { - type Err = (); - fn from_str(value: &str) -> Result { - Ok(match value { - "json" => Self::Json, - "code" => Self::Code, - "log" => Self::Log, - "search" => Self::Search, - "diff" => Self::Diff, - "html" => Self::Html, - "plain_text" => Self::PlainText, - _ => return Err(()), - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum CompressorKind { - SmartCrusher, - Code, - Log, - Search, - Diff, - Html, - MlText, - Generic, - None, -} - -impl CompressorKind { - pub fn as_str(self) -> &'static str { - match self { - Self::SmartCrusher => "smartcrusher", - Self::Code => "code", - Self::Log => "log", - Self::Search => "search", - Self::Diff => "diff", - Self::Html => "html", - Self::MlText => "ml_text", - Self::Generic => "generic", - Self::None => "none", - } - } -} - -impl std::str::FromStr for CompressorKind { - type Err = (); - fn from_str(value: &str) -> Result { - Ok(match value { - "smartcrusher" => Self::SmartCrusher, - "code" => Self::Code, - "log" => Self::Log, - "search" => Self::Search, - "diff" => Self::Diff, - "html" => Self::Html, - "ml_text" => Self::MlText, - "generic" => Self::Generic, - "none" => Self::None, - _ => return Err(()), - }) - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ContentHint { - pub mime: Option, - pub extension: Option, - pub source_tool: Option, - pub query: Option, - pub explicit: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct CompressOptions { - pub router_enabled: bool, - pub ccr_enabled: bool, - pub search_enabled: bool, - pub code_enabled: bool, - pub html_enabled: bool, - pub ml_text_enabled: bool, - pub min_bytes_to_compress: usize, - pub min_bytes_to_compress_log: usize, - pub ccr_min_tokens: usize, - pub lossy_without_ccr: bool, - pub max_inline_chars: Option, - pub code_target_ratio: Option, - pub chars_per_token: f32, -} - -impl Default for CompressOptions { - fn default() -> Self { - Self { - router_enabled: true, - ccr_enabled: true, - search_enabled: true, - code_enabled: true, - html_enabled: true, - ml_text_enabled: false, - min_bytes_to_compress: 2048, - min_bytes_to_compress_log: 512, - ccr_min_tokens: 500, - lossy_without_ccr: false, - max_inline_chars: None, - code_target_ratio: None, - chars_per_token: 4.0, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CompressedOutput { - pub text: String, - pub content_kind: ContentKind, - pub compressor: CompressorKind, - pub lossy: bool, - pub applied: bool, - pub ccr_token: Option, - pub original_bytes: usize, - pub compacted_bytes: usize, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CompactResponse { - pub text: String, - pub original_bytes: usize, - pub compacted_bytes: usize, - pub rule_id: String, - pub applied: bool, - pub content_kind: String, - pub compressor: String, - pub original_tokens: u64, - pub compacted_tokens: u64, -} - -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum RangeUnit { - Bytes, - Lines, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RetrieveRange { - pub start: usize, - pub end: usize, - pub unit: RangeUnit, -} - -#[derive(Debug, Clone, Copy, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CacheStats { - pub entries: usize, - pub bytes: usize, -} +//! The wire types shared with the separately compiled TinyJuice module. +//! +//! These were declared here — 259 lines of them, under a doc comment saying +//! they were "shared with" the module. They were shared by convention: the +//! module's copy was private to its adapter and the library's copy was the +//! library's, so neither was reachable from here and nothing checked that the +//! three agreed. A field added on one side was a decode failure on the other. +//! +//! `tinyjuice-bus` is that contract as an ordinary crate, and this module is a +//! re-export of it. The names below are the ones ~40 call sites in this crate +//! already use, so the paths are unchanged. +//! +//! `RangeUnit`, `RetrieveRange` and `CacheStats` come from the contract's +//! `wire` module rather than its `types` module — the split there is between +//! values the `tinyjuice` library itself uses and envelopes that exist only on +//! the bus. Nothing here needs to care which is which. + +pub use tinyjuice_bus::types::{ + AgentTokenjuiceCompression, CompressOptions, CompressedOutput, CompressorKind, ContentHint, + ContentKind, +}; +pub use tinyjuice_bus::wire::{ + CacheStats, CompactResponse, InstallRequest, RangeUnit, RetrieveRange, +}; #[cfg(test)] mod tests { diff --git a/src/openhuman/modules/documents.rs b/src/openhuman/modules/documents.rs index 62d3f2053e..38e28355bb 100644 --- a/src/openhuman/modules/documents.rs +++ b/src/openhuman/modules/documents.rs @@ -31,6 +31,7 @@ use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; use serde::Deserialize; use tinybus::stream::StreamRef; +use tinydocs_bus::names::methods; use super::{host, ops, registry}; use crate::openhuman::config::Config; @@ -101,7 +102,7 @@ pub async fn generate_docx( let (runtime, record) = ready(config).await?; let proxy = proxy(runtime, record)?; let handle: OutputRef = proxy - .call("GenerateDocx", (spec,)) + .call(methods::GENERATE_DOCX, (spec,)) .await .map_err(|error| classify(&error))?; collect(&proxy, handle).await @@ -128,7 +129,7 @@ pub async fn generate_pptx( // A text-only deck opens no stream: there is nothing to send, and an // empty stream is a round trip for nothing. proxy - .call("GeneratePptx", (deck, Option::::None)) + .call(methods::GENERATE_PPTX, (deck, Option::::None)) .await .map_err(|error| classify(&error))? } else { @@ -139,7 +140,7 @@ pub async fn generate_pptx( destination, path, interface, - member("GeneratePptx")?, + member(methods::GENERATE_PPTX)?, |stream| serde_json::json!([deck, stream]), images, ) @@ -166,7 +167,7 @@ pub async fn extract_text(config: &Config, document: &[u8]) -> Result Result { async fn collect(proxy: &tinybus::Proxy, handle: OutputRef) -> Result, DocumentCallError> { let result = read_all(proxy, &handle).await; if let Err(error) = proxy - .call::<()>("ReleaseOutput", (handle.output_id.clone(),)) + .call::<()>(methods::RELEASE_OUTPUT, (handle.output_id.clone(),)) .await { // Not fatal: the module expires what nobody reads. Worth a line, because @@ -282,7 +283,7 @@ async fn read_all( while (out.len() as u64) < handle.total_bytes { let encoded: String = proxy .call( - "ReadOutput", + methods::READ_OUTPUT, (handle.output_id.clone(), out.len() as u64, READ_CHUNK), ) .await diff --git a/src/openhuman/modules/documents_tests.rs b/src/openhuman/modules/documents_tests.rs index 2fb2649857..fca27342eb 100644 --- a/src/openhuman/modules/documents_tests.rs +++ b/src/openhuman/modules/documents_tests.rs @@ -129,3 +129,39 @@ async fn a_disabled_host_reports_unavailable_without_starting_a_broker() { Err(DocumentCallError::Unavailable(_)) )); } + +#[test] +fn the_registry_entry_matches_the_interface_this_client_calls() { + // The registry is a plain `const` table and cannot name a gated crate, so + // the bus name and object path are written out there by hand. This is what + // checks them against the contract's own constants — a mismatch is not a + // compile error, it is a `NameHasNoOwner` at first use, in the field, on + // whichever platform nobody tested. + let record = + crate::openhuman::modules::registry::find("tinydocs").expect("tinydocs is registered"); + assert_eq!(record.bus_name, tinydocs_bus::names::BUS_NAME); + assert_eq!(record.object_path, tinydocs_bus::names::OBJECT_PATH); +} + +#[test] +fn every_member_this_client_calls_is_one_the_contract_declares() { + // The five calls in this module are written as `tinydocs_bus` constants, so + // a rename upstream is a compile error here rather than a `MemberNotFound` + // at runtime. This pins the other direction: that the constants are the + // contract's whole surface, so a member added upstream shows up as an + // unused one here rather than being quietly unreachable. + use tinydocs_bus::names::methods; + let called = [ + methods::GENERATE_DOCX, + methods::GENERATE_PPTX, + methods::EXTRACT_TEXT, + methods::READ_OUTPUT, + methods::RELEASE_OUTPUT, + ]; + for member in tinydocs_bus::names::METHODS { + assert!( + called.contains(&member), + "the contract declares `{member}`, which this client never calls" + ); + } +} diff --git a/src/openhuman/modules/registry.rs b/src/openhuman/modules/registry.rs index 8100c1fb01..58f530a1f2 100644 --- a/src/openhuman/modules/registry.rs +++ b/src/openhuman/modules/registry.rs @@ -35,63 +35,63 @@ const TINYDOCS: ModuleRecord = ModuleRecord { description: "Document synthesis (.docx, .pptx) and PDF text extraction", bus_name: "ai.tinyhumans.tinydocs.Documents", object_path: "/ai/tinyhumans/tinydocs/Documents", - version: "0.1.13", - release_url: "https://github.com/tinyhumansai/tinydocs/releases/tag/v0.1.13", + version: "0.1.14", + release_url: "https://github.com/tinyhumansai/tinydocs/releases/tag/v0.1.14", assets: &[ PlatformAsset { host_key: "ubuntu-24.04-x86_64", - archive: "tinydocs-module-0.1.13-ubuntu-24.04-x86_64.tar.gz", - sha256: "43ad43b0fea00de3f82f960c5eae297b528334780905286f683857cbd7e7fa07", + archive: "tinydocs-module-0.1.14-ubuntu-24.04-x86_64.tar.gz", + sha256: "2dfee3d8d9322474114bf3bc1775f57ed7f8258d53c11a78fe5302538fdd0d1e", }, PlatformAsset { host_key: "ubuntu-24.04-arm64", - archive: "tinydocs-module-0.1.13-ubuntu-24.04-arm64.tar.gz", - sha256: "66a4d9a4cb1caea86fe6203cde54db06165d483c59e8f86b61439f257be7dff8", + archive: "tinydocs-module-0.1.14-ubuntu-24.04-arm64.tar.gz", + sha256: "0efb5c25babd13fea2c1ef0faef43bc6a06a9b1bd155b145fbdb03dbbe2875fa", }, PlatformAsset { host_key: "ubuntu-22.04-x86_64", - archive: "tinydocs-module-0.1.13-ubuntu-22.04-x86_64.tar.gz", - sha256: "3e3a7c2e774d75654a7e9074e41ad972a670f2a0dcf8ee2648dfdbb404edc7cb", + archive: "tinydocs-module-0.1.14-ubuntu-22.04-x86_64.tar.gz", + sha256: "fac4385075e0a1eb1f86355b9b96cae25a3a84bad30417ba3fd417db61ec6385", }, PlatformAsset { host_key: "ubuntu-22.04-arm64", - archive: "tinydocs-module-0.1.13-ubuntu-22.04-arm64.tar.gz", - sha256: "12f0c83a6239423be9001ec57cf9d53a50c639e3d67449646f48a9eef207f36b", + archive: "tinydocs-module-0.1.14-ubuntu-22.04-arm64.tar.gz", + sha256: "8f6e77a492668d446a47b65713324300da3e7319a77d6865487a938462528575", }, PlatformAsset { host_key: "macos-26-arm64", - archive: "tinydocs-module-0.1.13-macos-26-arm64.tar.gz", - sha256: "6a8edb36258a241c62497dd962c3690f0f287944663a7edc00602e652ac72298", + archive: "tinydocs-module-0.1.14-macos-26-arm64.tar.gz", + sha256: "9a086ed43ddfebd80aad4df832f9a996c1fadf46bc60c4f251db4e46b1acb319", }, PlatformAsset { host_key: "macos-26-x86_64", - archive: "tinydocs-module-0.1.13-macos-26-x86_64.tar.gz", - sha256: "dfcd0f79f6ea9ffd7c9f510f4007285a0cc7d434ddf286a9dc870468003d3784", + archive: "tinydocs-module-0.1.14-macos-26-x86_64.tar.gz", + sha256: "b43ffddbba88c1e54939419f1eb0f76b65bf6a9411bf12fe6f5929b448dfa51a", }, PlatformAsset { host_key: "macos-15-arm64", - archive: "tinydocs-module-0.1.13-macos-15-arm64.tar.gz", - sha256: "8b1be8ac2db781fd0ff8af8815e6dd408d79fd8c489032358447434a21bdf52a", + archive: "tinydocs-module-0.1.14-macos-15-arm64.tar.gz", + sha256: "9ffad3fd0464e35e66d3958a6f8b7bf2309f4af2ae8ca167b9d653231c47597d", }, PlatformAsset { host_key: "macos-15-x86_64", - archive: "tinydocs-module-0.1.13-macos-15-x86_64.tar.gz", - sha256: "c84dcf6b3fc4eac5985b56297e35eb730dc86c7717fdfe72886f9c189efc22ba", + archive: "tinydocs-module-0.1.14-macos-15-x86_64.tar.gz", + sha256: "f26e3bb312af83ef6dbf197b7193fc0cfab0ea21438b01de8fb64d290b9d5b0c", }, PlatformAsset { host_key: "windows-2025-x86_64", - archive: "tinydocs-module-0.1.13-windows-2025-x86_64.zip", - sha256: "30a0ef74959029ed385ee4a3e47f8f42bd4eeeb12c2d95030107fa7ac16d5dbe", + archive: "tinydocs-module-0.1.14-windows-2025-x86_64.zip", + sha256: "212f9822db5ac1698018326ac636224f55543dc7f4608bb06da3880cba71f79b", }, PlatformAsset { host_key: "windows-2022-x86_64", - archive: "tinydocs-module-0.1.13-windows-2022-x86_64.zip", - sha256: "f8a7097166074aff712e6847207c112f3afcc95a6a875177bcc167b46cd6d332", + archive: "tinydocs-module-0.1.14-windows-2022-x86_64.zip", + sha256: "7922905cce57a2d345fabe15ca4cb6c8d66c4e06edc496e1f096338173eb86a3", }, PlatformAsset { host_key: "windows-11-arm64", - archive: "tinydocs-module-0.1.13-windows-11-arm64.zip", - sha256: "366f92165c1a3ef4361568edacb0ca4053a0209efbf804730ab35ee37b743ee7", + archive: "tinydocs-module-0.1.14-windows-11-arm64.zip", + sha256: "e9664823b4b9ca083968ecc9bb3cb0b932c2288a4df027d21269c34673d040e4", }, ], load: LoadPolicy::Lazy, @@ -280,63 +280,63 @@ const TINYJUICE: ModuleRecord = ModuleRecord { description: "Content-aware tool-output compression and recoverable caching", bus_name: "ai.tinyhumans.tinyjuice.Compression", object_path: "/ai/tinyhumans/tinyjuice/Compression", - version: "0.2.2", - release_url: "https://github.com/tinyhumansai/tinyjuice/releases/tag/v0.2.2", + version: "0.2.4", + release_url: "https://github.com/tinyhumansai/tinyjuice/releases/tag/v0.2.4", assets: &[ PlatformAsset { host_key: "ubuntu-24.04-x86_64", - archive: "tinyjuice-module-0.2.2-ubuntu-24.04-x86_64.tar.gz", - sha256: "ed80892f82e9ba824bb1cc436adf2ad77bc4ba59205a3bdb1eecd96841797a16", + archive: "tinyjuice-module-0.2.4-ubuntu-24.04-x86_64.tar.gz", + sha256: "1427cd37740a6ff512f8743a5753789537a47133e2b3a09513026a275ec633b5", }, PlatformAsset { host_key: "ubuntu-24.04-arm64", - archive: "tinyjuice-module-0.2.2-ubuntu-24.04-arm64.tar.gz", - sha256: "91b16e77671c0c06ca3c413bddc7218b6d65453eb7b43d87d58b693fd8273a55", + archive: "tinyjuice-module-0.2.4-ubuntu-24.04-arm64.tar.gz", + sha256: "476ed4c41d5078e612d20af814cc36adf44b97a8c877f243fc11eaec283cb624", }, PlatformAsset { host_key: "ubuntu-22.04-x86_64", - archive: "tinyjuice-module-0.2.2-ubuntu-22.04-x86_64.tar.gz", - sha256: "fd8caf7fccb53328870fd26922aa9768d253cd4b3bf758967847d6512df03863", + archive: "tinyjuice-module-0.2.4-ubuntu-22.04-x86_64.tar.gz", + sha256: "f8677b0d8619ac36791408bbee2125e4f3ed586326da68fd1c2de49291c09b01", }, PlatformAsset { host_key: "ubuntu-22.04-arm64", - archive: "tinyjuice-module-0.2.2-ubuntu-22.04-arm64.tar.gz", - sha256: "10e70614aca9da5d108c7335b73238e81de3e9daaad8291a690ef5d2bb48e852", + archive: "tinyjuice-module-0.2.4-ubuntu-22.04-arm64.tar.gz", + sha256: "b406f1041849284ee71332e2bb74169469345cb64f24f005c6f76cf0fb39b655", }, PlatformAsset { host_key: "macos-26-arm64", - archive: "tinyjuice-module-0.2.2-macos-26-arm64.tar.gz", - sha256: "30dc34f2901e1581f72c1d718b80632268714193964031ad52151dd6f046b5b8", + archive: "tinyjuice-module-0.2.4-macos-26-arm64.tar.gz", + sha256: "816befb360ed56b3e43e868e4fe5b86f832bee2ca9f97c273649ed7323fb262b", }, PlatformAsset { host_key: "macos-26-x86_64", - archive: "tinyjuice-module-0.2.2-macos-26-x86_64.tar.gz", - sha256: "122bac614bb2d27717b0ce5d0661b1ee10810b2e3c3417f153daa7a783f706a9", + archive: "tinyjuice-module-0.2.4-macos-26-x86_64.tar.gz", + sha256: "9558cf2204cb8535103168fba3581e3ed7c36428a0a39e842a8da48b19ed26f6", }, PlatformAsset { host_key: "macos-15-arm64", - archive: "tinyjuice-module-0.2.2-macos-15-arm64.tar.gz", - sha256: "cf833e0315ecab66a6fd99695065745f04b1ceb5169d2e7d3227b9ff60828a0c", + archive: "tinyjuice-module-0.2.4-macos-15-arm64.tar.gz", + sha256: "c5fd72170af9bc201885b4563afe78bc9fe05635b583a1ae9f897d5512031f7e", }, PlatformAsset { host_key: "macos-15-x86_64", - archive: "tinyjuice-module-0.2.2-macos-15-x86_64.tar.gz", - sha256: "ce28e5c4e06dab98b376defd09d2c4f7fd85b235c0daae1a9bd5e941c8085833", + archive: "tinyjuice-module-0.2.4-macos-15-x86_64.tar.gz", + sha256: "f75f9d460d76ea8b557c26f915d2163769e8a6fa0aeab96c6e74a8c6d63d01a2", }, PlatformAsset { host_key: "windows-2025-x86_64", - archive: "tinyjuice-module-0.2.2-windows-2025-x86_64.zip", - sha256: "b22df6573abf7376252ce3f62e339870719dfceee9d8bfc0752b7f1cdd92ded0", + archive: "tinyjuice-module-0.2.4-windows-2025-x86_64.zip", + sha256: "5bc28d173497e0fcf088b5a88ceede1f9aff8f8430866439e8a6dbcbb5609e05", }, PlatformAsset { host_key: "windows-2022-x86_64", - archive: "tinyjuice-module-0.2.2-windows-2022-x86_64.zip", - sha256: "dc44e589fc50b2d5e33d493a2547e38db7e7e9a28012c616b3155db2ff15c5cf", + archive: "tinyjuice-module-0.2.4-windows-2022-x86_64.zip", + sha256: "518078ff8e7a4f76c4d0feff452e3fe3fd89b74cac048a5ea2de05d47bd3074c", }, PlatformAsset { host_key: "windows-11-arm64", - archive: "tinyjuice-module-0.2.2-windows-11-arm64.zip", - sha256: "0b9389abae5f3432a02f0c18bfea33187e7cc2634a12281f2bdb67bb5501e338", + archive: "tinyjuice-module-0.2.4-windows-11-arm64.zip", + sha256: "efb618098cb6a6bef37ad715d1abcbdea54673e410c8cac930b3e7af11bf032c", }, ], load: LoadPolicy::Lazy, @@ -361,63 +361,63 @@ const TINYVOICE: ModuleRecord = ModuleRecord { description: "Wake-word gating, command routing, hallucination detection, capture audio", bus_name: "ai.tinyhumans.tinyvoice.Voice", object_path: "/ai/tinyhumans/tinyvoice/Voice", - version: "0.1.3", - release_url: "https://github.com/tinyhumansai/tinyvoice/releases/tag/v0.1.3", + version: "0.1.5", + release_url: "https://github.com/tinyhumansai/tinyvoice/releases/tag/v0.1.5", assets: &[ PlatformAsset { host_key: "ubuntu-24.04-x86_64", - archive: "tinyvoice-module-0.1.3-ubuntu-24.04-x86_64.tar.gz", - sha256: "663a261827a84862b618e76061960364daf447d3e1b44bb1edefb7197707c188", + archive: "tinyvoice-module-0.1.5-ubuntu-24.04-x86_64.tar.gz", + sha256: "8d8db0f7ae600be60f7929f7d77272daa262203d1a67656b3b6a56c774b4ff66", }, PlatformAsset { host_key: "ubuntu-24.04-arm64", - archive: "tinyvoice-module-0.1.3-ubuntu-24.04-arm64.tar.gz", - sha256: "9197af7b50c847792f89263eda903c24bdf0f6240de20e0e3a49b36309cc89a8", + archive: "tinyvoice-module-0.1.5-ubuntu-24.04-arm64.tar.gz", + sha256: "6bb931a47a8cf120717d2f6829a37c67c731b485fdfcefeaa46c46e0859d5be1", }, PlatformAsset { host_key: "ubuntu-22.04-x86_64", - archive: "tinyvoice-module-0.1.3-ubuntu-22.04-x86_64.tar.gz", - sha256: "5f801a5134edf7ed39bf86ec2a8555795237352b73a055b6b0c63bc23ebc671d", + archive: "tinyvoice-module-0.1.5-ubuntu-22.04-x86_64.tar.gz", + sha256: "1693c95528850d0547ca70b28d7394fe7db9a20c4da70b22ec0b82fcff23c698", }, PlatformAsset { host_key: "ubuntu-22.04-arm64", - archive: "tinyvoice-module-0.1.3-ubuntu-22.04-arm64.tar.gz", - sha256: "1e1f0fb9a5d787d4fcfae92bbcb191ff41a8305b4e0c5092cb79b36cfab4845b", + archive: "tinyvoice-module-0.1.5-ubuntu-22.04-arm64.tar.gz", + sha256: "63101dc92a7e9c65e4609c983d7370b2d5de87f629d8593f6d5878c24fd1f479", }, PlatformAsset { host_key: "macos-26-arm64", - archive: "tinyvoice-module-0.1.3-macos-26-arm64.tar.gz", - sha256: "8994f439c8c14aad0a55c524fb20b33eddc5514bcdf79338952dfe1822ed1578", + archive: "tinyvoice-module-0.1.5-macos-26-arm64.tar.gz", + sha256: "034565947f76a524bdfba33bcc121197e766cda9433e659a23e46b218e7a3e37", }, PlatformAsset { host_key: "macos-26-x86_64", - archive: "tinyvoice-module-0.1.3-macos-26-x86_64.tar.gz", - sha256: "890f8bdc75917062416922bdd9220e3e11cb39ac92662a4ccc3fbc927fc3f864", + archive: "tinyvoice-module-0.1.5-macos-26-x86_64.tar.gz", + sha256: "08f1e74f35b9ed830cfb01b6339c3466916b1715b549faecc5de8b053e1a5465", }, PlatformAsset { host_key: "macos-15-arm64", - archive: "tinyvoice-module-0.1.3-macos-15-arm64.tar.gz", - sha256: "0def6647f68cba724bd36f4ccc9108739acde10487cd7e0ac19def642cb7ded5", + archive: "tinyvoice-module-0.1.5-macos-15-arm64.tar.gz", + sha256: "4d6f63a802a372cef4de397f5b6d16bd1c703a09444c48288bf5b9cc25633a19", }, PlatformAsset { host_key: "macos-15-x86_64", - archive: "tinyvoice-module-0.1.3-macos-15-x86_64.tar.gz", - sha256: "d58007d55d1d1547fbdbc830c8fa1e5c5d82b11768c3497f69aba4c8399e4a43", + archive: "tinyvoice-module-0.1.5-macos-15-x86_64.tar.gz", + sha256: "fe4582e8ea583f333bb7003bdc54bd24aafd602f20d1d091b32d54b923a83423", }, PlatformAsset { host_key: "windows-2025-x86_64", - archive: "tinyvoice-module-0.1.3-windows-2025-x86_64.zip", - sha256: "95226afb977b05a8f1fd3a27e86703580e1cf76f05ee033deca77d3108f35b53", + archive: "tinyvoice-module-0.1.5-windows-2025-x86_64.zip", + sha256: "d89e526e62ebf20361635029284d108ec5a4feb07899715a3de01e4bfacdaf43", }, PlatformAsset { host_key: "windows-2022-x86_64", - archive: "tinyvoice-module-0.1.3-windows-2022-x86_64.zip", - sha256: "539640590c24524fab9b99d622739ad4a60d80b5d1a99a132b6cf12fca63fcd9", + archive: "tinyvoice-module-0.1.5-windows-2022-x86_64.zip", + sha256: "11a7adf1669c7df3b8d9587eb5ca0a601b403d57bf99209c74b117a69fd57a8d", }, PlatformAsset { host_key: "windows-11-arm64", - archive: "tinyvoice-module-0.1.3-windows-11-arm64.zip", - sha256: "58bdcab2576664fea63abc7ffc88281ff053a9c371c5f5f784a19293848c0500", + archive: "tinyvoice-module-0.1.5-windows-11-arm64.zip", + sha256: "f39eeecfe54ec2eec9b850dbc4190a69e14de220aa671bac6f7cd889670227e9", }, ], load: LoadPolicy::Lazy, @@ -433,25 +433,75 @@ const TINYVOICE: ModuleRecord = ModuleRecord { /// Lazy, because a host that never runs a skill, a flow step, or a `node_exec` /// should not pay a download and a `dlopen` for the ability to. /// -/// # No pinned assets yet -/// -/// `assets` is deliberately empty: this build pins no published release. The -/// module still loads from a developer build named by `modules.local` or from -/// the module search path (`OPENHUMAN_MODULE_PATH`), which is how it is -/// exercised today. A download attempt reports that no artifact exists for this -/// platform, which is accurate. -/// -/// When the first release is cut, take the digests verbatim from that release's -/// `checksum.toml` — never from a local build, which would agree with itself no -/// matter what was served. +/// The digests below are v0.2.2's, taken verbatim from that release's +/// `checksum.toml`. Until it existed this record carried no assets at all and +/// the module was reachable only from a developer build named by +/// `modules.local` or found on `OPENHUMAN_MODULE_PATH` — so on any machine that +/// had not built it, the runtime domain was a set of tools that could not run. const TINYRUNTIME: ModuleRecord = ModuleRecord { id: "tinyruntime", description: "Language runtime resolution, installation, and pooled execution", bus_name: "ai.tinyhumans.runtime.Runtime", object_path: "/ai/tinyhumans/runtime/Runtime", - version: "0.1.0", - release_url: "https://github.com/tinyhumansai/tinyruntime/releases/tag/v0.1.0", - assets: &[], + version: "0.2.2", + release_url: "https://github.com/tinyhumansai/tinyruntime/releases/tag/v0.2.2", + assets: &[ + PlatformAsset { + host_key: "ubuntu-24.04-x86_64", + archive: "tinyruntime-0.2.2-ubuntu-24.04-x86_64.tar.gz", + sha256: "61f642e9c952889d12347beeb6399dd7240b599be21219488abc08ad86b70a82", + }, + PlatformAsset { + host_key: "ubuntu-24.04-arm64", + archive: "tinyruntime-0.2.2-ubuntu-24.04-arm64.tar.gz", + sha256: "99c8ace3a011fa08e5a526cc9c26e62951cc35f0d23512ea19494eb0d677a871", + }, + PlatformAsset { + host_key: "ubuntu-22.04-x86_64", + archive: "tinyruntime-0.2.2-ubuntu-22.04-x86_64.tar.gz", + sha256: "8f2e78662d43e8311291f621bbb61a123ab70d9edfd73177f7f6a92bd1c212c7", + }, + PlatformAsset { + host_key: "ubuntu-22.04-arm64", + archive: "tinyruntime-0.2.2-ubuntu-22.04-arm64.tar.gz", + sha256: "fbab3aa0c1ed44758446098ce6fca88c43344ff5b7ce03b0aa79000555a9f5ad", + }, + PlatformAsset { + host_key: "macos-26-arm64", + archive: "tinyruntime-0.2.2-macos-26-arm64.tar.gz", + sha256: "e968577c2df7aeac1cde63e0cb4155d79144ac995ed61cb0584f8ba2562ff748", + }, + PlatformAsset { + host_key: "macos-26-x86_64", + archive: "tinyruntime-0.2.2-macos-26-x86_64.tar.gz", + sha256: "c15d9d492f23796a330f5df53ac39730b15d72c6ca8ce1b09a1ac8fdf760d60a", + }, + PlatformAsset { + host_key: "macos-15-arm64", + archive: "tinyruntime-0.2.2-macos-15-arm64.tar.gz", + sha256: "122f4de043a2f252373a2beaf08ff7e91b3da1947f135a24578b3a09a2574656", + }, + PlatformAsset { + host_key: "macos-15-x86_64", + archive: "tinyruntime-0.2.2-macos-15-x86_64.tar.gz", + sha256: "e1dbfe11cea45df0703ec6bfa579de82740effde99f1977898776505a0ab82da", + }, + PlatformAsset { + host_key: "windows-2025-x86_64", + archive: "tinyruntime-0.2.2-windows-2025-x86_64.zip", + sha256: "893f0faaa3f4c1a4b530f63faaec7095f8582e55c1e768f4dfe1fe25a42864c4", + }, + PlatformAsset { + host_key: "windows-2022-x86_64", + archive: "tinyruntime-0.2.2-windows-2022-x86_64.zip", + sha256: "ebb59a8680782f0e2cd58450e1bf6423eba2839efd29c7a6380cd62e3f3ef9ef", + }, + PlatformAsset { + host_key: "windows-11-arm64", + archive: "tinyruntime-0.2.2-windows-11-arm64.zip", + sha256: "7b7accfb5758563ca1ce780b815f5a89d5b566efb7a811492432492794d37423", + }, + ], load: LoadPolicy::Lazy, }; @@ -468,15 +518,71 @@ const TINYRUNTIME: ModuleRecord = ModuleRecord { /// Lazy, and loaded by the same call that loads the router: a language is only /// worth its `dlopen` when something asks for that language. /// -/// See [`TINYRUNTIME`] on why `assets` is empty. +/// Released alongside the router and pinned the same way — see [`TINYRUNTIME`]. const TINYRUNTIME_NODEJS: ModuleRecord = ModuleRecord { id: "tinyruntime-nodejs", description: "Node.js runtime provider for tinyruntime", bus_name: "ai.tinyhumans.runtime.nodejs.Provider", object_path: "/ai/tinyhumans/runtime/nodejs/Provider", - version: "0.1.0", - release_url: "https://github.com/tinyhumansai/tinyruntime-nodejs/releases/tag/v0.1.0", - assets: &[], + version: "0.2.2", + release_url: "https://github.com/tinyhumansai/tinyruntime-nodejs/releases/tag/v0.2.2", + assets: &[ + PlatformAsset { + host_key: "ubuntu-24.04-x86_64", + archive: "tinyruntime-nodejs-0.2.2-ubuntu-24.04-x86_64.tar.gz", + sha256: "60bebfacfaccc5c899044fe542a07b1b2ef74ffeeca5d7f53ef0338b6dab4865", + }, + PlatformAsset { + host_key: "ubuntu-24.04-arm64", + archive: "tinyruntime-nodejs-0.2.2-ubuntu-24.04-arm64.tar.gz", + sha256: "ff9114e32db29de2a43df83e7d8b330926d5862cdb50ca20adc863d5d99becaf", + }, + PlatformAsset { + host_key: "ubuntu-22.04-x86_64", + archive: "tinyruntime-nodejs-0.2.2-ubuntu-22.04-x86_64.tar.gz", + sha256: "3f25a17d41226fa8cc56cd9f5f5bd447bff4b9f55c1bd68d7bf8ebbf10575aaa", + }, + PlatformAsset { + host_key: "ubuntu-22.04-arm64", + archive: "tinyruntime-nodejs-0.2.2-ubuntu-22.04-arm64.tar.gz", + sha256: "ec271b78487caaea5c5ae1951568a838be49b5df4d362d8855cb27ba243a8c44", + }, + PlatformAsset { + host_key: "macos-26-arm64", + archive: "tinyruntime-nodejs-0.2.2-macos-26-arm64.tar.gz", + sha256: "394d160e8de754e09121a52ae6a4b5a7b440c0035fb52cbdaa2dfe7ee523b7b0", + }, + PlatformAsset { + host_key: "macos-26-x86_64", + archive: "tinyruntime-nodejs-0.2.2-macos-26-x86_64.tar.gz", + sha256: "bbde43f8d839aacb34f735bbde2e8f56207a1a49fb5b07732a3be7b486243ce3", + }, + PlatformAsset { + host_key: "macos-15-arm64", + archive: "tinyruntime-nodejs-0.2.2-macos-15-arm64.tar.gz", + sha256: "83ea9c8ea1b43dc4e98cb585e98d254080c2070092b3c1458f19012df5ea3cd8", + }, + PlatformAsset { + host_key: "macos-15-x86_64", + archive: "tinyruntime-nodejs-0.2.2-macos-15-x86_64.tar.gz", + sha256: "6bdb686d1e857d6c28a49ab2ab87785d8c4fecbf7ef62ad218d7b3e159e2339a", + }, + PlatformAsset { + host_key: "windows-2025-x86_64", + archive: "tinyruntime-nodejs-0.2.2-windows-2025-x86_64.zip", + sha256: "36aab2547fbb7f336e15ecb66768661a4bd35f3da6179fc3efcd47bbb8d0df96", + }, + PlatformAsset { + host_key: "windows-2022-x86_64", + archive: "tinyruntime-nodejs-0.2.2-windows-2022-x86_64.zip", + sha256: "0beaf8ee4765b10f1d12d0ee0c872209935fa48184424842aa6fd299a6e3f5a8", + }, + PlatformAsset { + host_key: "windows-11-arm64", + archive: "tinyruntime-nodejs-0.2.2-windows-11-arm64.zip", + sha256: "d47571781dc17edfb0438943fbe2026417d33414904667ade0f9cb6de27e5733", + }, + ], load: LoadPolicy::Lazy, }; @@ -485,15 +591,71 @@ const TINYRUNTIME_NODEJS: ModuleRecord = ModuleRecord { /// Answers which host interpreters count, which standalone build to install, and /// what a warm Python worker is. It installs nothing itself. /// -/// See [`TINYRUNTIME`] on why `assets` is empty. +/// Released alongside the router and pinned the same way — see [`TINYRUNTIME`]. const TINYRUNTIME_PYTHON: ModuleRecord = ModuleRecord { id: "tinyruntime-python", description: "Python runtime provider for tinyruntime", bus_name: "ai.tinyhumans.runtime.python.Provider", object_path: "/ai/tinyhumans/runtime/python/Provider", - version: "0.1.0", - release_url: "https://github.com/tinyhumansai/tinyruntime-python/releases/tag/v0.1.0", - assets: &[], + version: "0.2.2", + release_url: "https://github.com/tinyhumansai/tinyruntime-python/releases/tag/v0.2.2", + assets: &[ + PlatformAsset { + host_key: "ubuntu-24.04-x86_64", + archive: "tinyruntime-python-0.2.2-ubuntu-24.04-x86_64.tar.gz", + sha256: "8d020d8af32f2735e646e164124a84027d260638a1d3cfa392e7c97de179eca6", + }, + PlatformAsset { + host_key: "ubuntu-24.04-arm64", + archive: "tinyruntime-python-0.2.2-ubuntu-24.04-arm64.tar.gz", + sha256: "49fb3458636a8247b9735d80a573538bec8c73f8323e9ad0e2eaf5715b88edf1", + }, + PlatformAsset { + host_key: "ubuntu-22.04-x86_64", + archive: "tinyruntime-python-0.2.2-ubuntu-22.04-x86_64.tar.gz", + sha256: "4f7e23f6f20df2820489f3cde4445e319c5b4c5285bb37e113112f7d83d37a57", + }, + PlatformAsset { + host_key: "ubuntu-22.04-arm64", + archive: "tinyruntime-python-0.2.2-ubuntu-22.04-arm64.tar.gz", + sha256: "89ca7864016bd62d2b247fc791b800acf7bbe8903bf40a12da2396e1396a9f63", + }, + PlatformAsset { + host_key: "macos-26-arm64", + archive: "tinyruntime-python-0.2.2-macos-26-arm64.tar.gz", + sha256: "2d091cbb29dc9d06996f290eaea8f03cf027e8fc9cff72824b9eae86d7ce5483", + }, + PlatformAsset { + host_key: "macos-26-x86_64", + archive: "tinyruntime-python-0.2.2-macos-26-x86_64.tar.gz", + sha256: "b0ec8c06202bf148463a087920387d3f243761756a570a334af16b9ba473267f", + }, + PlatformAsset { + host_key: "macos-15-arm64", + archive: "tinyruntime-python-0.2.2-macos-15-arm64.tar.gz", + sha256: "5577ed48e84d35ec07d0de8db29c840e0addcd5e54792a02b714e883a65a7ed8", + }, + PlatformAsset { + host_key: "macos-15-x86_64", + archive: "tinyruntime-python-0.2.2-macos-15-x86_64.tar.gz", + sha256: "e08fb6a06a47fd3a1e4e9ae1b6a52f42f3b78655c5f91f4e5dbd7448d6db19a4", + }, + PlatformAsset { + host_key: "windows-2025-x86_64", + archive: "tinyruntime-python-0.2.2-windows-2025-x86_64.zip", + sha256: "e22d5120ae58f9562a9861cd2c84a4d88ac692fa12d283ae047aafbe1a71adcc", + }, + PlatformAsset { + host_key: "windows-2022-x86_64", + archive: "tinyruntime-python-0.2.2-windows-2022-x86_64.zip", + sha256: "41f27a63ad1e5cc2559ed2fa11d698a775dad55763c7b5e5c884a3ef14f1a811", + }, + PlatformAsset { + host_key: "windows-11-arm64", + archive: "tinyruntime-python-0.2.2-windows-11-arm64.zip", + sha256: "0e96e8c0dbf1cfd497c8691928659c9f0bb3bf42a77eaa02bce59547f63b929e", + }, + ], load: LoadPolicy::Lazy, }; diff --git a/src/openhuman/modules/tokenjuice_host.rs b/src/openhuman/modules/tokenjuice_host.rs index 32fc93f60f..e04294bca8 100644 --- a/src/openhuman/modules/tokenjuice_host.rs +++ b/src/openhuman/modules/tokenjuice_host.rs @@ -2,8 +2,12 @@ use tinybus::ObjectPath; -const NAME: &str = "ai.tinyhumans.tinyjuice.MlHost"; -const PATH: &str = "/ai/tinyhumans/tinyjuice/MlHost"; +// The module calls *out* to this one: the ML plain-text compressor is the +// host's, not the module's. The names come from the contract so the two sides +// cannot drift — a mismatch here is a `NameHasNoOwner` the module swallows by +// falling back to a compressor that needs no ML runtime, which is a silent +// loss of compression rather than a failure anyone sees. +use tinyjuice_bus::names::{ML_HOST_NAME as NAME, ML_HOST_PATH as PATH}; #[derive(Clone)] struct MlHost; diff --git a/src/openhuman/modules/voice.rs b/src/openhuman/modules/voice.rs index f641a4ab0b..dcad2c0f08 100644 --- a/src/openhuman/modules/voice.rs +++ b/src/openhuman/modules/voice.rs @@ -29,7 +29,7 @@ //! than taking dictation down with it. The one thing none of them may do is //! guess: see [`is_hallucinated`]. -use serde::Deserialize; +use tinyvoice_bus::names::methods; use super::{host, ops, registry}; use crate::openhuman::config::Config; @@ -55,98 +55,37 @@ impl std::fmt::Display for VoiceCallError { } } -/// Which hallucination list applies, mirroring `tinyvoice::transcript::Mode`. -/// -/// Redeclared here rather than imported because this crate does not depend on -/// `tinyvoice` — the module is the only link, and its interface speaks strings. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HallucinationMode { - /// Push-to-talk dictation. Aggressive. - Dictation, - /// Chat voice input. Conservative. - Conversation, -} - -impl HallucinationMode { - /// The wire value the module expects. - fn as_wire(self) -> &'static str { - match self { - Self::Dictation => "dictation", - Self::Conversation => "conversation", - } +/// Which hallucination list applies. +/// +/// The contract's own type under the name the voice domain has always used for +/// it. It was redeclared here — with a comment saying it had to be, "because +/// this crate does not depend on `tinyvoice`" — and that is no longer true: +/// `tinyvoice-bus` is exactly that dependency, and it costs `serde` and +/// nothing else. +pub use tinyvoice_bus::transcript::Mode as HallucinationMode; + +/// The wire value for a screening mode. +/// +/// The interface takes the mode as a plain string argument rather than a JSON +/// value, so this reaches the same spelling the contract's `rename_all = +/// "snake_case"` derive produces without a `serde_json` round trip. The match +/// is exhaustive, so a variant added upstream is a compile error here rather +/// than a mode that silently screens as something else. +fn hallucination_mode_wire(mode: HallucinationMode) -> &'static str { + match mode { + HallucinationMode::Dictation => "dictation", + HallucinationMode::Conversation => "conversation", } } /// A recognised fast-path voice command, or `Unknown`. /// -/// Deserialized from the module's tagged JSON. The variants and their payload -/// names are the wire contract — renaming one here silently turns it into -/// `Unknown`, which is why [`VoiceIntent::Unknown`] carries the catch-all -/// `#[serde(other)]` and the tests below pin every tag. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -#[serde(tag = "intent", rename_all = "snake_case")] -pub enum VoiceIntent { - /// "play ". - Play { - /// The cleaned search query. - query: String, - }, - /// Pause playback. - Pause, - /// Resume playback. - Resume, - /// Skip to the next track. - Next, - /// Go back to the previous track. - Previous, - /// "open/launch/start ". - OpenApp { - /// The cleaned application name. - app: String, - }, - /// "set volume to N", absolute `0..=100`. - SetVolume { - /// Target volume percentage. - percent: u8, - }, - /// Raise the volume. - VolumeUp, - /// Lower the volume. - VolumeDown, - /// Mute audio output. - Mute, - /// Unmute audio output. - Unmute, - /// Not a confident fast command — defer to the agent. - #[serde(other)] - Unknown, -} - -impl VoiceIntent { - /// A stable, **non-PII** variant name, for logs and metrics. - /// - /// Never includes the `query` / `app` payloads. This path is fed by an - /// always-on microphone, so those fields can hold anything said in the - /// room: a log line naming the variant is diagnostics, and one naming the - /// query is a recording. - #[must_use] - pub fn kind(&self) -> &'static str { - match self { - Self::Play { .. } => "play", - Self::Pause => "pause", - Self::Resume => "resume", - Self::Next => "next", - Self::Previous => "previous", - Self::OpenApp { .. } => "open_app", - Self::SetVolume { .. } => "set_volume", - Self::VolumeUp => "volume_up", - Self::VolumeDown => "volume_down", - Self::Mute => "mute", - Self::Unmute => "unmute", - Self::Unknown => "unknown", - } - } -} +/// The contract's own type. `Unknown` carries `#[serde(other)]` upstream, so a +/// module newer than this host — which `is_compatible` permits, it only +/// requires the module's minor version to be at least the host's — reports an +/// intent this build has never heard of as `Unknown` and the utterance goes to +/// the agent, rather than failing to decode. +pub use tinyvoice_bus::VoiceIntent; /// Classify a command transcript into a fast-path intent. /// @@ -160,28 +99,32 @@ impl VoiceIntent { /// to the agent — the fast path is an optimisation, and losing it costs a round /// trip rather than the request. pub async fn route(config: &Config, transcript: &str) -> Result { - let json: String = call(config, "Route", (transcript,)).await?; + let json: String = call(config, methods::ROUTE, (transcript,)).await?; let intent: VoiceIntent = serde_json::from_str(&json) .map_err(|e| VoiceCallError::Failed(format!("could not decode intent: {e}")))?; - Ok(intent.clamped()) + Ok(clamped(intent)) } -impl VoiceIntent { - /// Bring payloads back inside the range the executors assume. - /// - /// The module already clamps a spoken volume to `0..=100`, so in practice - /// this changes nothing. It runs anyway because *this* type is decoded from - /// a wire payload, and `percent` is interpolated straight into an - /// `osascript` command by `voice::always_on::execute_intent`. A value the - /// host never checked reaching a shell command is the shape of bug worth - /// spending three lines to make impossible, rather than one that depends on - /// a remote clamp staying correct. - #[must_use] - fn clamped(self) -> Self { - match self { - Self::SetVolume { percent } if percent > 100 => Self::SetVolume { percent: 100 }, - other => other, +/// Bring payloads back inside the range the executors assume. +/// +/// The module already clamps a spoken volume to `0..=100`, so in practice this +/// changes nothing. It runs anyway because the value is decoded from a wire +/// payload, and `percent` is interpolated straight into an `osascript` command +/// by `voice::always_on::execute_intent`. A value the host never checked +/// reaching a shell command is the shape of bug worth spending three lines to +/// make impossible, rather than one that depends on a remote clamp staying +/// correct. +/// +/// It is a free function rather than an inherent method because the type is +/// the contract's now, and this is host policy: the contract describes what a +/// module may say, not what this host is willing to act on. +#[must_use] +fn clamped(intent: VoiceIntent) -> VoiceIntent { + match intent { + VoiceIntent::SetVolume { percent } if percent > 100 => { + VoiceIntent::SetVolume { percent: 100 } } + other => other, } } @@ -199,7 +142,7 @@ pub async fn extract_command( transcript: &str, wake_word: &str, ) -> Result, VoiceCallError> { - let command: String = call(config, "ExtractCommand", (transcript, wake_word)).await?; + let command: String = call(config, methods::EXTRACT_COMMAND, (transcript, wake_word)).await?; Ok(if command.is_empty() { None } else { @@ -220,7 +163,7 @@ pub async fn wake_word_present( transcript: &str, wake_word: &str, ) -> Result { - call(config, "WakeWordPresent", (transcript, wake_word)).await + call(config, methods::WAKE_WORD_PRESENT, (transcript, wake_word)).await } /// Whether an STT transcript looks like a hallucination rather than speech. @@ -239,7 +182,12 @@ pub async fn is_hallucinated( text: &str, mode: HallucinationMode, ) -> Result { - call(config, "IsHallucinated", (text, mode.as_wire())).await + call( + config, + methods::IS_HALLUCINATED, + (text, hallucination_mode_wire(mode)), + ) + .await } /// Downmix, resample to 16 kHz, optionally silence-gate, and frame as WAV. @@ -265,7 +213,7 @@ pub async fn prepare_capture( let encoded = encode_samples(samples); let wav: String = call( config, - "PrepareCapture", + methods::PREPARE_CAPTURE, (encoded, source_rate, channels, gate_threshold), ) .await?; @@ -283,65 +231,44 @@ pub async fn encode_wav( sample_rate: u32, ) -> Result, VoiceCallError> { let encoded = encode_samples(samples); - let wav: String = call(config, "EncodeWav", (encoded, sample_rate)).await?; + let wav: String = call(config, methods::ENCODE_WAV, (encoded, sample_rate)).await?; decode_audio(&wav) } -/// Tuning for a VAD session, mirroring `tinyvoice::vad::VadConfig`. -/// -/// Built from `voice_server` config by [`VadConfig::from_server_config`]. The -/// module has no such constructor on purpose — it does not know what OpenHuman -/// persists — so the mapping lives here. -#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)] -pub struct VadConfig { - /// Peak-RMS energy above which a frame counts as speech. - pub onset_threshold: f32, - /// How long energy must stay below `onset_threshold` before the utterance - /// closes. Bridges natural mid-sentence pauses. - pub hangover_ms: u32, - /// Minimum voiced duration for a segment to be emitted. - pub min_speech_ms: u32, - /// Hard ceiling on a single utterance. - pub max_utterance_ms: u32, -} - -impl VadConfig { - /// Build VAD tuning from the persisted voice-server config. - #[must_use] - pub fn from_server_config(c: &crate::openhuman::config::VoiceServerConfig) -> Self { - Self { - onset_threshold: c.vad_onset_threshold, - hangover_ms: c.vad_hangover_ms, - min_speech_ms: c.vad_min_speech_ms, - // Config stores seconds; the module speaks milliseconds. Clamped to - // at least 1ms so a zero or negative setting cannot make every - // utterance close on its first frame. - max_utterance_ms: (c.vad_max_utterance_secs * 1000.0).round().max(1.0) as u32, - } +/// Tuning for a VAD session. +/// +/// The contract's own type. There is no `from_server_config` on it and there +/// should not be: a crate that any host can link cannot know what *this* host +/// persists, so that mapping stays here as [`vad_config_from_server_config`]. +pub use tinyvoice_bus::vad::VadConfig; + +/// Build VAD tuning from the persisted voice-server config. +/// +/// A free function rather than an inherent method because [`VadConfig`] is the +/// contract's type. The unit conversion is the reason this exists at all: +/// OpenHuman persists the utterance ceiling in seconds and the module speaks +/// milliseconds. +#[must_use] +pub fn vad_config_from_server_config(c: &crate::openhuman::config::VoiceServerConfig) -> VadConfig { + VadConfig { + onset_threshold: c.vad_onset_threshold, + hangover_ms: c.vad_hangover_ms, + min_speech_ms: c.vad_min_speech_ms, + // Config stores seconds; the module speaks milliseconds. Clamped to at + // least 1ms so a zero or negative setting cannot make every utterance + // close on its first frame. + max_utterance_ms: (c.vad_max_utterance_secs * 1000.0).round().max(1.0) as u32, } } -/// What the segmenter reported at one frame. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum VadEvent { - /// Energy crossed the onset threshold — an utterance has begun. - SpeechStart { - /// Index of the frame, within the batch that was pushed. - frame: usize, - }, - /// An utterance closed. - SpeechEnd { - /// Index of the frame, within the batch that was pushed. - frame: usize, - /// Accumulated speech duration, excluding the trailing silence. - voiced_ms: u32, - /// False when the segment was too short to be worth transcribing. - emit: bool, - /// True when the close was forced by the utterance ceiling. - forced: bool, - }, -} +/// What the segmenter reported, and at which frame. +/// +/// The contract splits these in two — [`VadEvent`] is what happened, +/// [`IndexedVadEvent`] pairs it with the frame — where this host used to carry +/// one enum with `frame` repeated in every variant. The JSON is identical +/// either way: `IndexedVadEvent` flattens its event, so the wire still reads +/// `{"frame": 3, "kind": "speech_start"}`. +pub use tinyvoice_bus::vad::{IndexedVadEvent, VadEvent}; /// A live VAD session held by the module. /// @@ -364,7 +291,7 @@ impl VadSession { pub async fn open(config: &Config, vad: VadConfig) -> Result { let json = serde_json::to_string(&vad) .map_err(|e| VoiceCallError::Failed(format!("could not encode VAD config: {e}")))?; - let id: u64 = call(config, "VadOpen", (json,)).await?; + let id: u64 = call(config, methods::VAD_OPEN, (json,)).await?; Ok(Self { id }) } @@ -381,8 +308,8 @@ impl VadSession { config: &Config, frame_ms: u32, energies: &[f32], - ) -> Result, VoiceCallError> { - let json: String = call(config, "VadPush", (self.id, frame_ms, energies)).await?; + ) -> Result, VoiceCallError> { + let json: String = call(config, methods::VAD_PUSH, (self.id, frame_ms, energies)).await?; serde_json::from_str(&json) .map_err(|e| VoiceCallError::Failed(format!("could not decode VAD events: {e}"))) } @@ -394,7 +321,7 @@ impl VadSession { /// [`VoiceCallError`] when the module is unavailable or the session is not /// open. pub async fn is_speaking(&self, config: &Config) -> Result { - call(config, "VadIsSpeaking", (self.id,)).await + call(config, methods::VAD_IS_SPEAKING, (self.id,)).await } /// Abort any in-flight utterance without emitting an event. @@ -407,7 +334,7 @@ impl VadSession { /// [`VoiceCallError`] when the module is unavailable or the session is not /// open. pub async fn reset(&self, config: &Config) -> Result<(), VoiceCallError> { - call(config, "VadReset", (self.id,)).await + call(config, methods::VAD_RESET, (self.id,)).await } /// Release the session. Closing one that is already gone is not an error. @@ -416,7 +343,7 @@ impl VadSession { /// /// [`VoiceCallError`] only when the module itself is unreachable. pub async fn close(&self, config: &Config) -> Result<(), VoiceCallError> { - call(config, "VadClose", (self.id,)).await + call(config, methods::VAD_CLOSE, (self.id,)).await } } @@ -437,7 +364,7 @@ pub async fn prepare_frames( ) -> Result, VoiceCallError> { let encoded: String = call( config, - "PrepareFrames", + methods::PREPARE_FRAMES, (encode_samples(samples), source_rate, channels), ) .await?; @@ -456,7 +383,7 @@ pub async fn frame_energies( ) -> Result, VoiceCallError> { call( config, - "FrameEnergies", + methods::FRAME_ENERGIES, (encode_samples(samples), frame_len), ) .await @@ -480,7 +407,12 @@ pub async fn encode_wav_pcm16( use base64::Engine as _; let bytes: Vec = samples.iter().flat_map(|s| s.to_le_bytes()).collect(); let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - let wav: String = call(config, "EncodeWavPcm16", (encoded, sample_rate, channels)).await?; + let wav: String = call( + config, + methods::ENCODE_WAV_PCM16, + (encoded, sample_rate, channels), + ) + .await?; decode_audio(&wav) } diff --git a/src/openhuman/modules/voice_tests.rs b/src/openhuman/modules/voice_tests.rs index b5d5b0601a..2403971f5c 100644 --- a/src/openhuman/modules/voice_tests.rs +++ b/src/openhuman/modules/voice_tests.rs @@ -7,7 +7,10 @@ //! honest — `tinyvoice`'s own loader E2E, which drives a real module over //! a real broker against the published artifact. -use super::{encode_samples, HallucinationMode, VoiceCallError, VoiceIntent}; +use super::{ + clamped, encode_samples, hallucination_mode_wire, HallucinationMode, VoiceCallError, + VoiceIntent, +}; use crate::openhuman::config::Config; /// The intent tags are a wire contract with the module. A rename on either /// side turns a real command into `Unknown`, which degrades silently — the @@ -62,8 +65,14 @@ fn an_unrecognised_tag_degrades_to_unknown_rather_than_failing() { fn hallucination_modes_use_the_wire_spelling() { // The module rejects an unknown mode rather than defaulting, so a typo // here is a hard failure at runtime rather than a silent mode swap. - assert_eq!(HallucinationMode::Dictation.as_wire(), "dictation"); - assert_eq!(HallucinationMode::Conversation.as_wire(), "conversation"); + assert_eq!( + hallucination_mode_wire(HallucinationMode::Dictation), + "dictation" + ); + assert_eq!( + hallucination_mode_wire(HallucinationMode::Conversation), + "conversation" + ); } #[test] @@ -138,14 +147,15 @@ async fn a_disabled_host_reports_unavailable_without_starting_a_broker() { #[test] fn the_registry_entry_matches_the_interface_this_client_calls() { - // The bus name and object path are duplicated between the registry and - // the module's own source. A mismatch is not a compile error — it is a - // `NameHasNoOwner` at first use, in the field, on whichever platform - // nobody tested. + // The registry is a plain `const` table and cannot name a gated crate, so + // the bus name and object path are still written out there by hand. This + // is what checks them against the contract's own constants — a mismatch is + // not a compile error, it is a `NameHasNoOwner` at first use, in the field, + // on whichever platform nobody tested. let record = crate::openhuman::modules::registry::find("tinyvoice").expect("tinyvoice is registered"); - assert_eq!(record.bus_name, "ai.tinyhumans.tinyvoice.Voice"); - assert_eq!(record.object_path, "/ai/tinyhumans/tinyvoice/Voice"); + assert_eq!(record.bus_name, tinyvoice_bus::names::BUS_NAME); + assert_eq!(record.object_path, tinyvoice_bus::names::OBJECT_PATH); assert!( record.object_path.starts_with('/') && !record.object_path.contains('.'), "an object path with a dot in it is rejected by the loader, not by the compiler" @@ -281,7 +291,13 @@ async fn the_published_module_answers_through_this_client() { .await .expect("VadPush"); assert!( - matches!(events.as_slice(), [super::VadEvent::SpeechStart { .. }]), + matches!( + events.as_slice(), + [super::IndexedVadEvent { + event: super::VadEvent::SpeechStart, + .. + }] + ), "expected a single speech start, got {events:?}" ); assert!(session.is_speaking(&config).await.expect("is_speaking")); @@ -291,8 +307,11 @@ async fn the_published_module_answers_through_this_client() { .await .expect("VadPush"); match events.as_slice() { - [super::VadEvent::SpeechEnd { - voiced_ms, emit, .. + [super::IndexedVadEvent { + event: super::VadEvent::SpeechEnd { + voiced_ms, emit, .. + }, + .. }] => { assert_eq!(*voiced_ms, 120, "voiced time carries across pushes"); assert!(emit); @@ -314,7 +333,7 @@ fn an_out_of_range_volume_is_clamped_at_the_boundary() { serde_json::from_str(r#"{"intent":"set_volume","percent":255}"#).expect("decodes"); assert_eq!(decoded, VoiceIntent::SetVolume { percent: 255 }); assert_eq!( - decoded.clamped(), + clamped(decoded.clone()), VoiceIntent::SetVolume { percent: 100 }, "the clamp is what `route` applies before any caller sees the intent" ); @@ -322,7 +341,7 @@ fn an_out_of_range_volume_is_clamped_at_the_boundary() { // In-range values are untouched, including the boundary itself. for percent in [0u8, 1, 50, 100] { let intent = VoiceIntent::SetVolume { percent }; - assert_eq!(intent.clone().clamped(), intent); + assert_eq!(clamped(intent.clone()), intent); } } @@ -357,7 +376,7 @@ async fn every_entry_point_degrades_rather_than_hanging_when_the_module_is_gone( assert!(matches!( super::VadSession::open( &config, - super::VadConfig::from_server_config( + super::vad_config_from_server_config( &crate::openhuman::config::VoiceServerConfig::default() ) ) @@ -377,3 +396,43 @@ async fn every_entry_point_degrades_rather_than_hanging_when_the_module_is_gone( Err(VoiceCallError::Unavailable(_)) )); } + +#[test] +fn every_member_this_client_calls_is_one_the_contract_declares() { + // The fifteen call sites in this module are written as `tinyvoice_bus` + // constants, so a rename upstream is a compile error here rather than a + // `MemberNotFound` at runtime. This pins the other direction: a member the + // contract declares and this client never calls is either a gap in the + // client or a member that should not be in the contract, and either way it + // should be noticed here rather than discovered later. + use tinyvoice_bus::names::methods; + let called = [ + methods::ROUTE, + methods::EXTRACT_COMMAND, + methods::WAKE_WORD_PRESENT, + methods::IS_HALLUCINATED, + methods::VAD_OPEN, + methods::VAD_PUSH, + methods::VAD_IS_SPEAKING, + methods::VAD_RESET, + methods::VAD_CLOSE, + methods::PREPARE_FRAMES, + methods::FRAME_ENERGIES, + methods::ENCODE_WAV, + methods::ENCODE_WAV_PCM16, + methods::PREPARE_CAPTURE, + ]; + // `Segment` is the one deliberate omission: it segments a complete energy + // buffer in one call, and the always-on capture loop needs the stateful + // `Vad*` session instead, because a segmenter is a state machine across + // frames that arrive one at a time. + for member in tinyvoice_bus::names::METHODS { + if *member == methods::SEGMENT { + continue; + } + assert!( + called.contains(member), + "the contract declares `{member}`, which this client never calls" + ); + } +} diff --git a/src/openhuman/tools/impl/document/format/error/mod.rs b/src/openhuman/tools/impl/document/format/error/mod.rs deleted file mode 100644 index e46245b59d..0000000000 --- a/src/openhuman/tools/impl/document/format/error/mod.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! Crate-wide error and result types. -//! -//! Every fallible public function in this crate returns [`Result`], and every -//! failure mode is a distinct [`Error`] variant. Add a variant rather than -//! encoding new context into an existing message: callers match on variants, -//! and message text is not a stable API. -//! -//! The variants are deliberately *host-agnostic*. A host that surfaces these -//! to an LLM (the reason [`Error::InvalidInput`] carries a structured -//! `field` / `reason` pair rather than a formatted sentence) maps them onto -//! its own tool-error shape; a host writing to disk maps them onto its own. -//! Nothing here knows about artifacts, timeouts, or async runtimes — those are -//! the host's concerns, because only the host knows its own deadline policy. - -/// Errors returned by this crate. -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -#[non_exhaustive] -pub enum Error { - /// A document spec failed validation before any synthesis was attempted. - /// - /// `field` names the offending path in the spec using the same dotted / - /// indexed notation the JSON input uses (`sections[2].bullets[0]`), so an - /// LLM that produced the spec can self-correct without re-reading the - /// whole schema. `reason` states the violated constraint. - #[error("invalid input for field '{field}': {reason}")] - InvalidInput { - /// Path of the offending field within the spec. - field: String, - /// The constraint that was violated. - reason: String, - }, - - /// The underlying document library failed to synthesise the output. - /// - /// `detail` is the library's own error rendered as text and truncated to a - /// bounded length, so the variant never carries an unbounded payload back - /// to a caller that forwards it to a model. - #[error("document generation failed: {detail}")] - GenerationFailed { - /// Truncated underlying library error. - detail: String, - }, - - /// The underlying library failed to extract text from an input document. - /// - /// Distinct from [`Error::GenerationFailed`] because the two have opposite - /// causes and opposite remedies: generation fails on *our* output path and - /// usually means a bug or an exhausted resource, whereas extraction fails on - /// *someone else's* input and usually means the document is damaged, - /// encrypted, or carries no extractable text layer at all. A caller that - /// retries one should not retry the other. - /// - /// `detail` is truncated on the same bound as `GenerationFailed`. - #[error("text extraction failed: {detail}")] - ExtractionFailed { - /// Truncated underlying library error. - detail: String, - }, -} - -impl Error { - /// Maximum length, in Unicode scalar values, of a [`Error::GenerationFailed`] - /// detail string. - pub const MAX_DETAIL_CHARS: usize = 500; - - /// Suffix appended when a detail string is truncated. - const TRUNCATION_SUFFIX: &'static str = " […truncated]"; - - /// Build a [`Error::GenerationFailed`] with `raw` truncated (UTF-8-safe) to - /// [`Error::MAX_DETAIL_CHARS`]. - /// - /// Truncation counts characters, not bytes, so a multi-byte error message - /// can never be cut mid-codepoint. - #[must_use] - pub fn generation_failed(raw: &str) -> Self { - Self::GenerationFailed { - detail: Self::truncate_detail(raw), - } - } - - /// Truncate `raw` to [`Error::MAX_DETAIL_CHARS`] characters, appending the - /// standard truncation suffix when anything was dropped. - #[must_use] - pub fn truncate_detail(raw: &str) -> String { - if raw.chars().count() <= Self::MAX_DETAIL_CHARS { - return raw.to_string(); - } - let keep = Self::MAX_DETAIL_CHARS.saturating_sub(Self::TRUNCATION_SUFFIX.chars().count()); - let mut out: String = raw.chars().take(keep).collect(); - out.push_str(Self::TRUNCATION_SUFFIX); - out - } - - /// Build an [`Error::ExtractionFailed`] with `raw` truncated (UTF-8-safe) to - /// [`Error::MAX_DETAIL_CHARS`]. - #[must_use] - pub fn extraction_failed(raw: &str) -> Self { - Self::ExtractionFailed { - detail: Self::truncate_detail(raw), - } - } - - /// Build an [`Error::InvalidInput`] for `field` violating `reason`. - #[must_use] - pub fn invalid_input(field: impl Into, reason: impl Into) -> Self { - Self::InvalidInput { - field: field.into(), - reason: reason.into(), - } - } -} - -/// The crate's standard result type. -/// -/// Use this alias in public signatures instead of spelling out -/// `std::result::Result`. -pub type Result = std::result::Result; - -#[cfg(test)] -mod test; diff --git a/src/openhuman/tools/impl/document/format/error/test.rs b/src/openhuman/tools/impl/document/format/error/test.rs deleted file mode 100644 index 7bc5815fc1..0000000000 --- a/src/openhuman/tools/impl/document/format/error/test.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Unit tests for the crate-wide error type. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::Error; - -#[test] -fn short_details_are_left_intact() { - let err = Error::generation_failed("boom"); - assert_eq!( - err, - Error::GenerationFailed { - detail: "boom".to_string() - } - ); -} - -#[test] -fn long_details_are_truncated_with_a_suffix() { - let raw = "x".repeat(Error::MAX_DETAIL_CHARS * 2); - let Error::GenerationFailed { detail } = Error::generation_failed(&raw) else { - panic!("expected GenerationFailed"); - }; - assert_eq!(detail.chars().count(), Error::MAX_DETAIL_CHARS); - assert!(detail.ends_with("[…truncated]")); -} - -#[test] -fn truncation_never_splits_a_multi_byte_character() { - // Every character is 4 bytes, so a byte-based truncation would panic or - // produce invalid UTF-8. Counting characters keeps the boundary valid. - let raw = "🦀".repeat(Error::MAX_DETAIL_CHARS * 2); - let detail = Error::truncate_detail(&raw); - assert_eq!(detail.chars().count(), Error::MAX_DETAIL_CHARS); - assert!(detail.starts_with('🦀')); -} - -#[test] -fn detail_at_exactly_the_cap_is_not_truncated() { - let raw = "y".repeat(Error::MAX_DETAIL_CHARS); - assert_eq!(Error::truncate_detail(&raw), raw); -} - -#[test] -fn invalid_input_carries_the_field_path_verbatim() { - let err = Error::invalid_input("sections[2].bullets[0]", "must be ≤ 10 chars"); - assert_eq!( - err, - Error::InvalidInput { - field: "sections[2].bullets[0]".to_string(), - reason: "must be ≤ 10 chars".to_string(), - } - ); -} diff --git a/src/openhuman/tools/impl/document/format/mod.rs b/src/openhuman/tools/impl/document/format/mod.rs deleted file mode 100644 index 81c45df4d8..0000000000 --- a/src/openhuman/tools/impl/document/format/mod.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! Agent-friendly document synthesis and text extraction in Rust. -//! -//! Typed, validated document contracts shared with the document bus module. -//! They are built for hosts that let a language model produce documents: -//! the spec types are the JSON tool schema, validation rejects a malformed -//! spec with a structured [`Error::InvalidInput`] naming the exact field so -//! the model can self-correct, and synthesis returns a plain byte buffer. -//! -//! # What this module deliberately does not do -//! -//! No filesystem access, no subprocesses, no async runtime, no deadline -//! handling. Synthesis runs in the document bus module; this host module owns -//! the wire contract and validation only. -//! -//! # Layout -//! -//! - [`error`](self::Error) — the crate-wide [`Error`] and [`Result`]. -//! - [`spec`] — the typed document specs and their validation. Compiled in -//! every build, including `--no-default-features`, so a host whose synthesis -//! happens elsewhere still shares one definition of the wire contract. -//! -//! Writer and extractor implementations are intentionally absent: the host -//! sends these contract values over TinyBus. - -mod error; - -pub mod spec; - -pub use error::{Error, Result}; diff --git a/src/openhuman/tools/impl/document/format/spec/document/mod.rs b/src/openhuman/tools/impl/document/format/spec/document/mod.rs deleted file mode 100644 index d96e78f429..0000000000 --- a/src/openhuman/tools/impl/document/format/spec/document/mod.rs +++ /dev/null @@ -1,261 +0,0 @@ -//! The `.docx` document spec: the typed description a caller hands to -//! `docx::generate`, plus the size limits every spec is validated against. -//! -//! The spec is the crate's wire contract. It derives `Serialize` / -//! `Deserialize` with `deny_unknown_fields` because the usual caller is an -//! LLM tool boundary: the same struct that drives synthesis is the one whose -//! JSON schema the model is shown, and a typo'd field name should be a loud -//! rejection rather than a silently ignored key. -//! -//! Limits are public consts rather than private constants so a host can quote -//! the exact number in its own tool description and stay in lockstep with what -//! validation actually enforces. -//! -//! Nothing in this module depends on the `docx` feature or on `docx-rs`: it is -//! `serde` plus the crate error type. A host that only needs to *describe* and -//! *validate* a document — because synthesis happens elsewhere, in another -//! process or behind a message bus — can therefore depend on this crate with -//! `default-features = false` and still share one definition of the contract. - -use serde::{Deserialize, Serialize}; - -use crate::openhuman::tools::implementations::document::format::{Error, Result}; - -/// Maximum number of sections a single document may contain. -/// -/// Bounds generation time and output size; a caller with more material is -/// expected to split it across multiple documents. -pub const MAX_SECTIONS: usize = 128; - -/// Maximum length, in Unicode scalar values, of a short text field — the -/// document title, the author byline, or a section heading. -pub const MAX_TEXT_CHARS: usize = 2_000; - -/// Maximum length, in Unicode scalar values, of a single body paragraph or -/// bullet item. -/// -/// More generous than [`MAX_TEXT_CHARS`]: prose paragraphs legitimately run -/// far longer than a heading. -pub const MAX_PARAGRAPH_CHARS: usize = 20_000; - -/// Maximum number of body paragraphs in a single section. -pub const MAX_PARAGRAPHS_PER_SECTION: usize = 200; - -/// Maximum number of bullet-list items in a single section. -pub const MAX_BULLETS_PER_SECTION: usize = 200; - -/// Aggregate cap on all renderable text across the whole document — the -/// title, the author byline, and every section's heading, paragraphs, and -/// bullets — in Unicode scalar values. -/// -/// The per-field and per-section limits above bound each individual piece, but -/// not their product — `MAX_SECTIONS × MAX_PARAGRAPHS_PER_SECTION × -/// MAX_PARAGRAPH_CHARS` alone is over 500M characters, so a spec satisfying -/// every other limit could still build a multi-hundred-megabyte document in -/// memory. This total keeps the worst case bounded to a few megabytes of text -/// while staying generous for any real document. -pub const MAX_TOTAL_CHARS: usize = 2_000_000; - -/// One section of the document, rendered in spec order. -/// -/// A section is an optional heading followed by any number of body paragraphs -/// and/or a bullet list. At least one of the three must carry renderable text — -/// a wholly blank section is rejected by [`DocumentSpec::validate`] rather than -/// silently rendering nothing. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DocumentSection { - /// Section heading, rendered as a bold heading paragraph. Optional: a - /// section may be pure body text under the document title. - #[serde(default)] - pub heading: Option, - /// Body paragraphs, each rendered as its own paragraph, in order. - /// Blank and whitespace-only entries are dropped during synthesis. - #[serde(default)] - pub paragraphs: Vec, - /// Bullet-list items, rendered as a single-level bulleted list after the - /// section's body paragraphs. Blank and whitespace-only entries are - /// dropped during synthesis. - #[serde(default)] - pub bullets: Vec, -} - -impl DocumentSection { - /// Returns `true` when the section carries no renderable content at all — - /// the heading is absent or blank, and every paragraph and bullet is blank. - /// - /// Synthesis trims and drops blank entries, so a section holding only - /// `[" "]` would render as nothing despite carrying entries. Validation - /// uses this to reject that case up front. - #[must_use] - pub fn is_blank(&self) -> bool { - let has_heading = self - .heading - .as_deref() - .is_some_and(|h| !h.trim().is_empty()); - let has_paragraph = self.paragraphs.iter().any(|p| !p.trim().is_empty()); - let has_bullet = self.bullets.iter().any(|b| !b.trim().is_empty()); - !(has_heading || has_paragraph || has_bullet) - } -} - -/// A complete `.docx` document spec. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DocumentSpec { - /// Document title, rendered as the leading title paragraph. Required and - /// non-blank. - pub title: String, - /// Optional author byline, rendered as an italic line beneath the title. - #[serde(default)] - pub author: Option, - /// Sections, in display order. Must contain at least one entry. - #[serde(default)] - pub sections: Vec, -} - -impl DocumentSpec { - /// Total renderable text across the whole spec, in Unicode scalar values. - /// - /// Sums with saturating arithmetic so an adversarial spec cannot overflow - /// the counter into a small value that passes the aggregate check. - #[must_use] - pub fn total_chars(&self) -> usize { - let mut total = self.title.chars().count(); - if let Some(author) = self.author.as_deref() { - total = total.saturating_add(author.chars().count()); - } - for section in &self.sections { - if let Some(heading) = section.heading.as_deref() { - total = total.saturating_add(heading.chars().count()); - } - for paragraph in §ion.paragraphs { - total = total.saturating_add(paragraph.chars().count()); - } - for bullet in §ion.bullets { - total = total.saturating_add(bullet.chars().count()); - } - } - total - } - - /// Check the spec against every documented size limit. - /// - /// Callers do not have to invoke this: `docx::generate` validates before it - /// synthesises anything. It is public so a host can reject a malformed - /// spec at its own boundary — an LLM tool call, say — and hand back the - /// structured [`Error::InvalidInput`] before paying for a blocking hop, a - /// process boundary, or a bus round trip. - /// - /// # Errors - /// - /// Returns [`Error::InvalidInput`] naming the first field that violates a - /// limit. Fields are checked in spec order (title, author, sections, then - /// each section's contents) so the reported field is stable for a given - /// spec. - pub fn validate(&self) -> Result<()> { - if self.title.trim().is_empty() { - return Err(Error::invalid_input("title", "must not be empty")); - } - if self.title.chars().count() > MAX_TEXT_CHARS { - return Err(Error::invalid_input( - "title", - format!("must be ≤ {MAX_TEXT_CHARS} chars"), - )); - } - // Running total across every renderable field — title, author, and all - // section contents — checked as each field is processed. A spec can pass - // every per-field limit yet blow the aggregate budget, and checking - // incrementally rejects it as soon as the budget is crossed without a - // second pass over the whole spec. - let over_budget = || { - Error::invalid_input( - "sections", - format!("total document text must be ≤ {MAX_TOTAL_CHARS} chars"), - ) - }; - let mut total = self.title.chars().count(); - if let Some(author) = self.author.as_deref() { - if author.chars().count() > MAX_TEXT_CHARS { - return Err(Error::invalid_input( - "author", - format!("must be ≤ {MAX_TEXT_CHARS} chars"), - )); - } - total = total.saturating_add(author.chars().count()); - } - if self.sections.is_empty() { - return Err(Error::invalid_input( - "sections", - "must contain at least one section", - )); - } - if self.sections.len() > MAX_SECTIONS { - return Err(Error::invalid_input( - "sections", - format!("must contain ≤ {MAX_SECTIONS} sections"), - )); - } - - for (i, section) in self.sections.iter().enumerate() { - if section.is_blank() { - return Err(Error::invalid_input( - format!("sections[{i}]"), - "must have at least one of heading / paragraphs / bullets", - )); - } - if let Some(heading) = section.heading.as_deref() { - if heading.chars().count() > MAX_TEXT_CHARS { - return Err(Error::invalid_input( - format!("sections[{i}].heading"), - format!("must be ≤ {MAX_TEXT_CHARS} chars"), - )); - } - total = total.saturating_add(heading.chars().count()); - if total > MAX_TOTAL_CHARS { - return Err(over_budget()); - } - } - if section.paragraphs.len() > MAX_PARAGRAPHS_PER_SECTION { - return Err(Error::invalid_input( - format!("sections[{i}].paragraphs"), - format!("must contain ≤ {MAX_PARAGRAPHS_PER_SECTION} paragraphs"), - )); - } - for (p, paragraph) in section.paragraphs.iter().enumerate() { - if paragraph.chars().count() > MAX_PARAGRAPH_CHARS { - return Err(Error::invalid_input( - format!("sections[{i}].paragraphs[{p}]"), - format!("must be ≤ {MAX_PARAGRAPH_CHARS} chars"), - )); - } - total = total.saturating_add(paragraph.chars().count()); - if total > MAX_TOTAL_CHARS { - return Err(over_budget()); - } - } - if section.bullets.len() > MAX_BULLETS_PER_SECTION { - return Err(Error::invalid_input( - format!("sections[{i}].bullets"), - format!("must contain ≤ {MAX_BULLETS_PER_SECTION} bullets"), - )); - } - for (b, bullet) in section.bullets.iter().enumerate() { - if bullet.chars().count() > MAX_PARAGRAPH_CHARS { - return Err(Error::invalid_input( - format!("sections[{i}].bullets[{b}]"), - format!("must be ≤ {MAX_PARAGRAPH_CHARS} chars"), - )); - } - total = total.saturating_add(bullet.chars().count()); - if total > MAX_TOTAL_CHARS { - return Err(over_budget()); - } - } - } - Ok(()) - } -} - -#[cfg(test)] -mod test; diff --git a/src/openhuman/tools/impl/document/format/spec/document/test.rs b/src/openhuman/tools/impl/document/format/spec/document/test.rs deleted file mode 100644 index 724302217e..0000000000 --- a/src/openhuman/tools/impl/document/format/spec/document/test.rs +++ /dev/null @@ -1,272 +0,0 @@ -//! Unit tests for the wire contracts: validation, the blank/aggregate rules, -//! and JSON round-tripping. -//! -//! These are deliberately separate from the format modules' tests. They must -//! pass in a build with every format feature off, because the spec is the half -//! of the crate a bus- or process-boundary host shares without the codec. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::{ - DocumentSection, DocumentSpec, MAX_BULLETS_PER_SECTION, MAX_PARAGRAPHS_PER_SECTION, - MAX_PARAGRAPH_CHARS, MAX_SECTIONS, MAX_TEXT_CHARS, MAX_TOTAL_CHARS, -}; -use crate::openhuman::tools::implementations::document::format::Error; - -/// One valid section carrying a heading, a paragraph, and a bullet. -fn section() -> DocumentSection { - DocumentSection { - heading: Some("Overview".to_string()), - paragraphs: vec!["A body paragraph.".to_string()], - bullets: vec!["A bullet".to_string()], - } -} - -/// A minimal valid spec; each test mutates one field to drive a single branch. -fn spec() -> DocumentSpec { - DocumentSpec { - title: "Charter".to_string(), - author: Some("Alice".to_string()), - sections: vec![section()], - } -} - -/// Assert `spec` is rejected with an `InvalidInput` naming `field`. -fn assert_rejects(spec: &DocumentSpec, field: &str) { - match spec.validate() { - Err(Error::InvalidInput { field: f, .. }) => { - assert_eq!(f, field, "unexpected rejected field"); - } - other => panic!("expected InvalidInput({field}), got {other:?}"), - } -} - -#[test] -fn accepts_a_well_formed_spec() { - assert!(spec().validate().is_ok()); -} - -#[test] -fn rejects_a_blank_title() { - let mut s = spec(); - s.title = " ".to_string(); - assert_rejects(&s, "title"); -} - -#[test] -fn rejects_an_over_long_title() { - let mut s = spec(); - s.title = "t".repeat(MAX_TEXT_CHARS + 1); - assert_rejects(&s, "title"); -} - -#[test] -fn rejects_an_over_long_author() { - let mut s = spec(); - s.author = Some("a".repeat(MAX_TEXT_CHARS + 1)); - assert_rejects(&s, "author"); -} - -#[test] -fn rejects_a_spec_with_no_sections() { - let mut s = spec(); - s.sections.clear(); - assert_rejects(&s, "sections"); -} - -#[test] -fn rejects_too_many_sections() { - let mut s = spec(); - s.sections = vec![section(); MAX_SECTIONS + 1]; - assert_rejects(&s, "sections"); -} - -#[test] -fn rejects_a_wholly_blank_section() { - // Every entry is present but whitespace-only, so synthesis would drop all - // of them and render nothing. Validation catches it instead. - let mut s = spec(); - s.sections = vec![DocumentSection { - heading: Some(" ".to_string()), - paragraphs: vec!["\t".to_string()], - bullets: vec![String::new()], - }]; - assert_rejects(&s, "sections[0]"); -} - -#[test] -fn rejects_an_over_long_heading_naming_its_index() { - let mut s = spec(); - s.sections.push(DocumentSection { - heading: Some("h".repeat(MAX_TEXT_CHARS + 1)), - ..section() - }); - assert_rejects(&s, "sections[1].heading"); -} - -#[test] -fn rejects_too_many_paragraphs() { - let mut s = spec(); - s.sections[0].paragraphs = vec!["p".to_string(); MAX_PARAGRAPHS_PER_SECTION + 1]; - assert_rejects(&s, "sections[0].paragraphs"); -} - -#[test] -fn rejects_an_over_long_paragraph_naming_its_index() { - let mut s = spec(); - s.sections[0].paragraphs = vec!["ok".to_string(), "p".repeat(MAX_PARAGRAPH_CHARS + 1)]; - assert_rejects(&s, "sections[0].paragraphs[1]"); -} - -#[test] -fn rejects_too_many_bullets() { - let mut s = spec(); - s.sections[0].bullets = vec!["b".to_string(); MAX_BULLETS_PER_SECTION + 1]; - assert_rejects(&s, "sections[0].bullets"); -} - -#[test] -fn rejects_an_over_long_bullet_naming_its_index() { - let mut s = spec(); - s.sections[0].bullets = vec!["ok".to_string(), "b".repeat(MAX_PARAGRAPH_CHARS + 1)]; - assert_rejects(&s, "sections[0].bullets[1]"); -} - -#[test] -fn rejects_a_spec_over_the_aggregate_character_budget() { - // Each individual field is within its own limit; only the sum is not. One - // section with just enough max-length paragraphs to cross MAX_TOTAL_CHARS - // reproduces that without allocating hundreds of megabytes: repeating a - // whole section MAX_SECTIONS times (the original fixture) built ~512 MB - // of paragraph text before validation ever ran. - let paragraph_count = MAX_TOTAL_CHARS / MAX_PARAGRAPH_CHARS + 1; - assert!(paragraph_count <= MAX_PARAGRAPHS_PER_SECTION); - let paragraph = "x".repeat(MAX_PARAGRAPH_CHARS); - let big = DocumentSection { - heading: Some("Heading".to_string()), - paragraphs: vec![paragraph; paragraph_count], - bullets: vec![], - }; - let s = DocumentSpec { - title: "Huge".to_string(), - author: None, - sections: vec![big], - }; - // Sanity: this spec passes every per-field check. - assert!(s.sections.len() <= MAX_SECTIONS); - assert_rejects(&s, "sections"); -} - -#[test] -fn rejects_an_aggregate_overrun_that_a_bullet_crosses() { - // The heading and paragraph loops each carry their own budget check; so does - // the bullet loop, and only a spec whose overrun lands on a bullet drives - // that third branch. - let bullet = "b".repeat(MAX_PARAGRAPH_CHARS); - let bullet_count = MAX_TOTAL_CHARS / MAX_PARAGRAPH_CHARS + 1; - assert!(bullet_count <= MAX_BULLETS_PER_SECTION); - let s = DocumentSpec { - title: "Bullets".to_string(), - author: None, - sections: vec![DocumentSection { - heading: None, - paragraphs: vec![], - bullets: vec![bullet; bullet_count], - }], - }; - assert_rejects(&s, "sections"); -} - -#[test] -fn rejects_an_aggregate_overrun_that_a_heading_crosses() { - // Headings cannot reach the aggregate cap on their own: MAX_SECTIONS × - // MAX_TEXT_CHARS is 256_000, two orders of magnitude under MAX_TOTAL_CHARS. - // Driving the heading branch therefore means spending the budget down to a - // single character of headroom in an earlier section, then letting a - // perfectly legal heading cross it. - let title = "Headings"; - let filler_count = MAX_TOTAL_CHARS / MAX_PARAGRAPH_CHARS - 1; - assert!(filler_count <= MAX_PARAGRAPHS_PER_SECTION); - let used = title.chars().count() + filler_count * MAX_PARAGRAPH_CHARS; - // Leave exactly one character of headroom. - let tail = MAX_TOTAL_CHARS - used - 1; - assert!(tail <= MAX_PARAGRAPH_CHARS); - - let mut paragraphs = vec!["p".repeat(MAX_PARAGRAPH_CHARS); filler_count]; - paragraphs.push("p".repeat(tail)); - - let s = DocumentSpec { - title: title.to_string(), - author: None, - sections: vec![ - DocumentSection { - heading: None, - paragraphs, - bullets: vec![], - }, - DocumentSection { - // Two characters against one character of headroom. - heading: Some("hh".to_string()), - paragraphs: vec![], - bullets: vec![], - }, - ], - }; - assert!(s.sections.len() <= MAX_SECTIONS); - assert_rejects(&s, "sections"); -} - -#[test] -fn is_blank_reflects_content_presence() { - assert!(!section().is_blank()); - assert!(DocumentSection { - heading: None, - paragraphs: vec![], - bullets: vec![], - } - .is_blank()); - // A heading alone is enough content. - assert!(!DocumentSection { - heading: Some("Only a heading".to_string()), - paragraphs: vec![], - bullets: vec![], - } - .is_blank()); -} - -#[test] -fn total_chars_sums_every_text_field() { - let s = DocumentSpec { - title: "abcd".to_string(), // 4 - author: Some("xy".to_string()), // 2 - sections: vec![DocumentSection { - heading: Some("hij".to_string()), // 3 - paragraphs: vec!["pq".to_string()], // 2 - bullets: vec!["b".to_string()], // 1 - }], - }; - assert_eq!(s.total_chars(), 12); -} - -#[test] -fn spec_round_trips_through_json() { - let s = spec(); - let json = serde_json::to_string(&s).expect("serialises"); - let back: DocumentSpec = serde_json::from_str(&json).expect("deserialises"); - assert_eq!(back, s); -} - -#[test] -fn spec_rejects_unknown_json_fields() { - // `deny_unknown_fields` makes a typo'd key a loud rejection rather than a - // silently ignored one — the whole point at an LLM tool boundary. - let json = r#"{"title":"T","sections":[],"titel":"typo"}"#; - assert!(serde_json::from_str::(json).is_err()); -} - -#[test] -fn spec_defaults_optional_fields() { - let s: DocumentSpec = serde_json::from_str(r#"{"title":"T"}"#).expect("deserialises"); - assert_eq!(s.author, None); - assert!(s.sections.is_empty()); -} diff --git a/src/openhuman/tools/impl/document/format/spec/image/mod.rs b/src/openhuman/tools/impl/document/format/spec/image/mod.rs deleted file mode 100644 index 4fab9d934e..0000000000 --- a/src/openhuman/tools/impl/document/format/spec/image/mod.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! Raster-image identification for specs that embed images. -//! -//! Two formats are supported, PNG and JPEG, and the restriction is deliberate -//! rather than incidental: the OOXML presentation writer this crate drives -//! declares no `webp` default in the generated `[Content_Types].xml`, and its -//! automatic format detection misclassifies `webp` as PNG — producing a part -//! `PowerPoint` refuses to render. Accepting only what can actually be embedded -//! turns that into a clean rejection at the boundary. -//! -//! Identification is done by reading the container header directly, in about a -//! hundred lines and with no dependencies, rather than by pulling in a decoding -//! stack. Nothing here decodes pixels: it answers "which format is this" and -//! "what are its native dimensions", which is all a layout engine needs to -//! place an image with the right aspect ratio. -//! -//! Like the rest of [`crate::openhuman::tools::implementations::document::format::spec`], this module is compiled in every build. A -//! host resolving image bytes has to identify and measure them to *build* a -//! spec, and that must not require the writer. - -use serde::{Deserialize, Serialize}; - -/// A raster image format that can be embedded in a generated document. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "UPPERCASE")] -pub enum ImageFormat { - /// Portable Network Graphics. - Png, - /// JPEG / JFIF. - Jpeg, -} - -impl ImageFormat { - /// The format's canonical OOXML name — `"PNG"` or `"JPEG"`. - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Png => "PNG", - Self::Jpeg => "JPEG", - } - } - - /// Identify `bytes` by its container header. - /// - /// Returns `None` for a truncated header or any format other than the two - /// embeddable ones — including GIF, WebP and BMP, which are recognisable - /// but not embeddable. - #[must_use] - pub fn sniff(bytes: &[u8]) -> Option { - if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) { - Some(Self::Png) - } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { - Some(Self::Jpeg) - } else { - None - } - } - - /// Native `(width, height)` of `bytes` in pixels, read from the header. - /// - /// Returns `None` when the header is truncated or malformed, or when either - /// dimension is zero — a degenerate image cannot be placed aspect-correctly - /// and is rejected rather than divided by. - #[must_use] - pub fn dimensions(self, bytes: &[u8]) -> Option<(u32, u32)> { - match self { - Self::Png => png_dimensions(bytes), - Self::Jpeg => jpeg_dimensions(bytes), - } - } -} - -impl std::fmt::Display for ImageFormat { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -/// PNG: 8-byte signature, then an `IHDR` chunk whose width / height are -/// big-endian `u32`s at byte offsets 16 and 20. -fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { - if bytes.len() < 24 || &bytes[12..16] != b"IHDR" { - return None; - } - let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]); - let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); - if w == 0 || h == 0 { - return None; - } - Some((w, h)) -} - -/// JPEG: walk the marker segments until a Start-Of-Frame is hit; its payload -/// carries height then width as big-endian `u16`s. -fn jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { - let mut i = 2; // skip the leading FF D8 SOI - while i + 3 < bytes.len() { - if bytes[i] != 0xFF { - i += 1; - continue; - } - let marker = bytes[i + 1]; - i += 2; - // Standalone markers carry no length field: padding fill bytes, TEM, - // RSTn, SOI and EOI. Reading the next two bytes as a length here would - // desynchronise the walk and reject a valid file — TEM in particular is - // legal before the frame header. - if marker == 0xFF - || marker == 0x01 - || marker == 0xD8 - || marker == 0xD9 - || (0xD0..=0xD7).contains(&marker) - { - continue; - } - if i + 1 >= bytes.len() { - return None; - } - let seg_len = u16::from_be_bytes([bytes[i], bytes[i + 1]]) as usize; - if seg_len < 2 { - return None; - } - // SOF markers carrying frame dimensions. Excludes 0xC4 (DHT), - // 0xC8 (JPG) and 0xCC (DAC), which share the 0xCn range but are not - // frame headers. - let is_sof = matches!( - marker, - 0xC0 | 0xC1 - | 0xC2 - | 0xC3 - | 0xC5 - | 0xC6 - | 0xC7 - | 0xC9 - | 0xCA - | 0xCB - | 0xCD - | 0xCE - | 0xCF - ); - if is_sof { - // segment: [len_hi len_lo precision h_hi h_lo w_hi w_lo ...] - if i + 6 >= bytes.len() { - return None; - } - let h = u32::from(u16::from_be_bytes([bytes[i + 3], bytes[i + 4]])); - let w = u32::from(u16::from_be_bytes([bytes[i + 5], bytes[i + 6]])); - if w == 0 || h == 0 { - return None; - } - return Some((w, h)); - } - i += seg_len; - } - None -} - -// Visible crate-wide under `cfg(test)`: the `png` / `jpeg` header builders here -// are the fixtures every image-carrying spec and every synthesis test needs, and -// one honest builder beats a base64 blob copied into three files. -#[cfg(test)] -pub(crate) mod test; diff --git a/src/openhuman/tools/impl/document/format/spec/image/test.rs b/src/openhuman/tools/impl/document/format/spec/image/test.rs deleted file mode 100644 index 90206fce8a..0000000000 --- a/src/openhuman/tools/impl/document/format/spec/image/test.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Unit tests for image identification and header measurement. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::{jpeg_dimensions, png_dimensions, ImageFormat}; - -/// A 1×1 PNG assembled byte-for-byte: signature, `IHDR`, `IDAT`, `IEND`. -/// -/// Built literally rather than decoded from base64 so the fixture needs no -/// dependency and the offsets under test are visible in the source. -pub(crate) fn png(width: u32, height: u32) -> Vec { - let mut out = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; - out.extend_from_slice(&13u32.to_be_bytes()); // IHDR length - out.extend_from_slice(b"IHDR"); - out.extend_from_slice(&width.to_be_bytes()); - out.extend_from_slice(&height.to_be_bytes()); - out.extend_from_slice(&[0x08, 0x06, 0x00, 0x00, 0x00]); // depth, colour, etc. - out.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // CRC placeholder - out.extend_from_slice(&0u32.to_be_bytes()); // empty IDAT - out.extend_from_slice(b"IDAT"); - out.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); - out.extend_from_slice(&0u32.to_be_bytes()); - out.extend_from_slice(b"IEND"); - out.extend_from_slice(&[0xAE, 0x42, 0x60, 0x82]); - out -} - -/// A minimal JPEG: SOI, an APP0 stub, then an SOF0 declaring `height × width`. -pub(crate) fn jpeg(width: u16, height: u16) -> Vec { - let mut out = vec![ - 0xFF, 0xD8, // SOI - 0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00, // APP0, len=4, 2 payload bytes - 0xFF, 0xC0, 0x00, 0x0B, // SOF0, len=11 - 0x08, // precision - ]; - out.extend_from_slice(&height.to_be_bytes()); - out.extend_from_slice(&width.to_be_bytes()); - out.extend_from_slice(&[0x03, 0x00, 0x00, 0x00]); // components (filler) - out.extend_from_slice(&[0xFF, 0xD9]); // EOI - out -} - -#[test] -fn sniffs_png_and_jpeg() { - assert_eq!(ImageFormat::sniff(&png(1, 1)), Some(ImageFormat::Png)); - assert_eq!(ImageFormat::sniff(&jpeg(7, 5)), Some(ImageFormat::Jpeg)); -} - -#[test] -fn rejects_non_images_and_unembeddable_formats() { - assert_eq!(ImageFormat::sniff(b"not an image"), None); - // GIF and WebP are recognisable, but the writer cannot embed either. - assert_eq!(ImageFormat::sniff(b"GIF89a....."), None); - assert_eq!(ImageFormat::sniff(b"RIFF\0\0\0\0WEBP"), None); - assert_eq!(ImageFormat::sniff(&[]), None); -} - -#[test] -fn reads_png_dimensions() { - assert_eq!(ImageFormat::Png.dimensions(&png(1, 1)), Some((1, 1)), "1x1"); - assert_eq!( - ImageFormat::Png.dimensions(&png(1920, 1080)), - Some((1920, 1080)) - ); -} - -#[test] -fn reads_jpeg_dimensions() { - assert_eq!(ImageFormat::Jpeg.dimensions(&jpeg(7, 5)), Some((7, 5))); -} - -#[test] -fn truncated_headers_yield_none() { - assert_eq!(png_dimensions(&[0x89, 0x50, 0x4E, 0x47]), None); - assert_eq!(jpeg_dimensions(&[0xFF, 0xD8]), None); -} - -#[test] -fn a_png_without_an_ihdr_chunk_yields_none() { - let mut bytes = png(4, 4); - bytes[12..16].copy_from_slice(b"XXXX"); - assert_eq!(png_dimensions(&bytes), None); -} - -#[test] -fn a_zero_dimension_yields_none() { - // Degenerate images cannot be placed aspect-correctly; they are rejected - // rather than divided by. - assert_eq!(png_dimensions(&png(0, 8)), None); - assert_eq!(png_dimensions(&png(8, 0)), None); - assert_eq!(jpeg_dimensions(&jpeg(0, 8)), None); - assert_eq!(jpeg_dimensions(&jpeg(8, 0)), None); -} - -#[test] -fn a_jpeg_with_no_start_of_frame_yields_none() { - // SOI, then an APP0 segment and EOI — a valid marker stream carrying no - // frame header at all. - let bytes = vec![ - 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00, 0xFF, 0xD9, 0x00, 0x00, - ]; - assert_eq!(jpeg_dimensions(&bytes), None); -} - -#[test] -fn a_jpeg_with_a_degenerate_segment_length_yields_none() { - // A declared segment length below the two length bytes themselves would - // make the walk loop forever if it were trusted. - let bytes = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x01, 0x00, 0x00, 0x00]; - assert_eq!(jpeg_dimensions(&bytes), None); -} - -#[test] -fn a_jpeg_skips_standalone_and_non_frame_markers_before_the_frame() { - // Restart markers and a DHT (0xC4, in the 0xCn range but not a frame - // header) must both be stepped over rather than mistaken for an SOF. - let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xD0, 0xFF, 0xFF]; - bytes.extend_from_slice(&[0xFF, 0xC4, 0x00, 0x04, 0x00, 0x00]); // DHT - bytes.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x0B, 0x08]); - bytes.extend_from_slice(&11u16.to_be_bytes()); // height - bytes.extend_from_slice(&22u16.to_be_bytes()); // width - bytes.extend_from_slice(&[0x03, 0x00, 0x00, 0x00, 0xFF, 0xD9]); - assert_eq!(jpeg_dimensions(&bytes), Some((22, 11))); -} - -#[test] -fn a_jpeg_with_a_tem_marker_before_the_frame_is_still_measured() { - // TEM (0xFF01) carries no length field. Reading the next two bytes as one - // desynchronises the walk and rejects a valid file. - let mut bytes = vec![0xFF, 0xD8, 0xFF, 0x01]; - bytes.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x0B, 0x08]); - bytes.extend_from_slice(&33u16.to_be_bytes()); // height - bytes.extend_from_slice(&44u16.to_be_bytes()); // width - bytes.extend_from_slice(&[0x03, 0x00, 0x00, 0x00, 0xFF, 0xD9]); - assert_eq!(jpeg_dimensions(&bytes), Some((44, 33))); -} - -#[test] -fn format_renders_its_ooxml_name() { - assert_eq!(ImageFormat::Png.as_str(), "PNG"); - assert_eq!(ImageFormat::Jpeg.as_str(), "JPEG"); - assert_eq!(ImageFormat::Jpeg.to_string(), "JPEG"); -} - -#[test] -fn format_round_trips_through_json_as_its_ooxml_name() { - let json = serde_json::to_string(&ImageFormat::Png).expect("serialises"); - assert_eq!(json, r#""PNG""#); - let back: ImageFormat = serde_json::from_str(&json).expect("deserialises"); - assert_eq!(back, ImageFormat::Png); -} diff --git a/src/openhuman/tools/impl/document/format/spec/mod.rs b/src/openhuman/tools/impl/document/format/spec/mod.rs deleted file mode 100644 index 21f8a2eaeb..0000000000 --- a/src/openhuman/tools/impl/document/format/spec/mod.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! The wire contracts: typed document specs and their validation, with no -//! dependency on any format writer. -//! -//! Every format module in this crate (`docx`, `pptx`, …) synthesises bytes from -//! a spec defined here. The split matters for two reasons: -//! -//! 1. **A host can share the contract without paying for the codec.** This -//! module is `serde` plus the crate [`Error`](crate::openhuman::tools::implementations::document::format::Error) — nothing else. -//! It is compiled in *every* build, including `--no-default-features`, so a -//! host whose synthesis happens elsewhere (in another process, or behind a -//! message bus) still gets the one authoritative definition of the spec -//! instead of re-declaring it and drifting. -//! 2. **Validation is cheap and belongs at the boundary.** The specs validate -//! themselves without touching a writer, so a host can reject a malformed -//! LLM tool call before paying for a blocking hop or a round trip. -//! -//! # Where things live -//! -//! - [`document`] — `.docx`: [`DocumentSpec`], [`DocumentSection`]. -//! - [`presentation`] — `.pptx`: [`PresentationSpec`], [`SlideSpec`], -//! [`SlideImage`]. -//! - [`image`] — [`ImageFormat`], for specs that embed raster images. -//! -//! **Types are re-exported here; limits are not.** Each format's limits stay -//! inside its own module, because the same name means a different thing in each -//! — `document::MAX_TEXT_CHARS` bounds a heading, `presentation::MAX_TEXT_CHARS` -//! bounds a bullet — and flattening them would put two distinct constants under -//! one name. Reach for `spec::presentation::MAX_SLIDES` and read it as the -//! sentence it is. -//! -//! The format modules re-export both the types and the limits they consume, so -//! `crate::openhuman::tools::implementations::document::format::docx::DocumentSpec` and [`crate::openhuman::tools::implementations::document::format::spec::DocumentSpec`] name the -//! same type. -//! -//! [`crate::openhuman::tools::implementations::document::format::spec::DocumentSpec`]: DocumentSpec - -pub mod document; -pub mod image; -pub mod presentation; - -pub use document::{DocumentSection, DocumentSpec}; -pub use image::ImageFormat; -pub use presentation::wire::{WirePresentationSpec, WireSlideImage, WireSlideSpec}; -#[allow(unused_imports)] -pub use presentation::{PresentationSpec, SlideImage, SlideSpec}; diff --git a/src/openhuman/tools/impl/document/format/spec/presentation/mod.rs b/src/openhuman/tools/impl/document/format/spec/presentation/mod.rs deleted file mode 100644 index 9e030540cc..0000000000 --- a/src/openhuman/tools/impl/document/format/spec/presentation/mod.rs +++ /dev/null @@ -1,340 +0,0 @@ -//! The `.pptx` presentation spec: the typed description a caller hands to -//! `pptx::generate`, plus the size limits every spec is validated against. -//! -//! Same contract rules as [`crate::openhuman::tools::implementations::document::format::spec::document`] — `deny_unknown_fields`, -//! public limits, `validate` before synthesis — with one structural difference -//! worth understanding. -//! -//! # Images are bytes here, not references -//! -//! A [`SlideImage`] carries the image *bytes*, its format, and its native pixel -//! dimensions. It deliberately does **not** carry a path, a URL, or an -//! application-specific identifier, because resolving any of those is host -//! policy this crate has no business holding: which directories an agent may -//! read, whether a given identifier belongs to the caller, and whether fetching -//! a URL is an acceptable request to originate are all questions with different -//! answers in every host. A host resolves indirection under its own rules and -//! hands over the resulting bytes. -//! -//! [`SlideImage::from_bytes`] does the mechanical half of that hand-off: -//! identify the format and read the dimensions, or reject the bytes. It needs -//! no format writer, so a host can build and validate a whole spec in a build -//! with the `pptx` feature off. - -use serde::{Deserialize, Serialize}; - -use crate::openhuman::tools::implementations::document::format::spec::image::ImageFormat; -use crate::openhuman::tools::implementations::document::format::{Error, Result}; - -/// Maximum number of content slides a single deck may contain. -/// -/// Bounds generation time and output size; a caller with more material is -/// expected to split it across multiple decks. -pub const MAX_SLIDES: usize = 64; - -/// Maximum length, in Unicode scalar values, of any single text field — the -/// deck title, the author byline, the theme hint, a slide title, a slide body, -/// one bullet, the speaker notes, or an image caption. -pub const MAX_TEXT_CHARS: usize = 2_000; - -/// Maximum number of bullets on a single slide. -/// -/// Higher counts produce a slide nobody can read, and bloat the output. -pub const MAX_BULLETS_PER_SLIDE: usize = 32; - -/// Maximum number of images attached to a single slide. -/// -/// The single-column layout stacks images vertically in the lower band of the -/// slide; past this count each one is too small to read. -pub const MAX_IMAGES_PER_SLIDE: usize = 6; - -/// Maximum number of images across the whole deck. -/// -/// Bounds the embedded media payload regardless of how the images are -/// distributed across slides. -pub const MAX_IMAGES_PER_DECK: usize = 8; - -/// Maximum size, in bytes, of a single embedded image. -pub const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024; - -/// One image embedded on a slide. -/// -/// Construct with [`SlideImage::from_bytes`] rather than by hand: it derives -/// `format` and the dimensions from the bytes, which keeps the three fields -/// consistent by construction. [`PresentationSpec::validate`] re-checks that -/// consistency, because a spec can also arrive over a wire. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SlideImage { - /// The encoded image, as PNG or JPEG bytes. - pub bytes: Vec, - /// The format of `bytes`. - pub format: ImageFormat, - /// Native width in pixels, used to place the image without distorting it. - pub width_px: u32, - /// Native height in pixels, used to place the image without distorting it. - pub height_px: u32, - /// Optional caption, rendered as a bullet beneath the image. - #[serde(default)] - pub caption: Option, -} - -impl SlideImage { - /// Identify and measure `bytes`, producing a consistent [`SlideImage`]. - /// - /// # Errors - /// - /// Returns [`Error::InvalidInput`] when `bytes` is empty, exceeds - /// [`MAX_IMAGE_BYTES`], is not PNG or JPEG, or carries a header this crate - /// cannot measure. - pub fn from_bytes(bytes: Vec, caption: Option) -> Result { - if bytes.is_empty() { - return Err(Error::invalid_input("bytes", "must not be empty")); - } - if bytes.len() > MAX_IMAGE_BYTES { - return Err(Error::invalid_input( - "bytes", - format!("must be ≤ {MAX_IMAGE_BYTES} bytes"), - )); - } - let format = ImageFormat::sniff(&bytes) - .ok_or_else(|| Error::invalid_input("bytes", "must be a PNG or JPEG image"))?; - let (width_px, height_px) = format.dimensions(&bytes).ok_or_else(|| { - Error::invalid_input( - "bytes", - format!("{format} header is truncated or malformed"), - ) - })?; - Ok(Self { - bytes, - format, - width_px, - height_px, - caption, - }) - } -} - -/// One content slide of the deck, rendered in spec order. -/// -/// At least one of `title`, `body`, or `bullets` must carry renderable text. -/// Images alone are not enough — a slide holding only an image and no label -/// reads as a rendering bug rather than a design choice, and synthesis drops -/// blank text anyway. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SlideSpec { - /// Slide title. May be blank for a visually minimal slide, as long as the - /// body or bullets carry text. - #[serde(default)] - pub title: String, - /// Body text, rendered above the bullets. Plain text only. - #[serde(default)] - pub body: Option, - /// Bullets, rendered after the body text. - #[serde(default)] - pub bullets: Vec, - /// Speaker notes attached to the slide. - #[serde(default)] - pub speaker_notes: Option, - /// Images, stacked in a single column beneath the text. - #[serde(default)] - pub images: Vec, -} - -impl SlideSpec { - /// Returns `true` when the slide carries no renderable text at all — the - /// title, body, and every bullet are absent or blank. - /// - /// Synthesis trims and drops blank entries, so a slide holding only - /// `[" "]` would render without text despite carrying entries. - #[must_use] - pub fn is_textless(&self) -> bool { - let has_title = !self.title.trim().is_empty(); - let has_body = self.body.as_deref().is_some_and(|b| !b.trim().is_empty()); - let has_bullets = self.bullets.iter().any(|b| !b.trim().is_empty()); - !(has_title || has_body || has_bullets) - } -} - -/// A complete `.pptx` presentation spec. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PresentationSpec { - /// Deck title, rendered on a leading title slide. Required and non-blank. - pub title: String, - /// Optional author byline, rendered beneath the deck title. - #[serde(default)] - pub author: Option, - /// Optional theme hint. - /// - /// Accepted and validated but not yet acted on: synthesis uses the writer's - /// default template regardless. It is part of the contract so a host's tool - /// schema does not have to change when template selection lands. - #[serde(default)] - pub theme: Option, - /// Content slides, in display order. Must contain at least one entry. - #[serde(default)] - pub slides: Vec, -} - -impl PresentationSpec { - /// Total number of images across every slide. - #[must_use] - pub fn image_count(&self) -> usize { - self.slides - .iter() - .map(|slide| slide.images.len()) - .sum::() - } - - /// Check the spec against every documented size limit, and check that each - /// image's declared format and dimensions match its bytes. - /// - /// Callers do not have to invoke this: `pptx::generate` validates before it - /// synthesises anything. It is public so a host can reject a malformed spec - /// at its own boundary — an LLM tool call, say — and hand back the - /// structured [`Error::InvalidInput`] before paying for a blocking hop, a - /// process boundary, or a bus round trip. - /// - /// # Errors - /// - /// Returns [`Error::InvalidInput`] naming the first field that violates a - /// limit. Fields are checked in spec order, so the reported field is stable - /// for a given spec. - pub fn validate(&self) -> Result<()> { - if self.title.trim().is_empty() { - return Err(Error::invalid_input("title", "must not be empty")); - } - Self::check_text_len("title", &self.title)?; - if let Some(author) = self.author.as_deref() { - Self::check_text_len("author", author)?; - } - if let Some(theme) = self.theme.as_deref() { - Self::check_text_len("theme", theme)?; - } - if self.slides.is_empty() { - return Err(Error::invalid_input( - "slides", - "must contain at least one slide", - )); - } - if self.slides.len() > MAX_SLIDES { - return Err(Error::invalid_input( - "slides", - format!("must contain ≤ {MAX_SLIDES} slides"), - )); - } - // Checked across the whole deck rather than per slide: the per-slide cap - // bounds readability, this one bounds the embedded media payload however - // the images are distributed. - if self.image_count() > MAX_IMAGES_PER_DECK { - return Err(Error::invalid_input( - "slides[].images", - format!("deck must contain ≤ {MAX_IMAGES_PER_DECK} images total"), - )); - } - - for (i, slide) in self.slides.iter().enumerate() { - if slide.is_textless() { - return Err(Error::invalid_input( - format!("slides[{i}]"), - "must have at least one of title / body / bullets", - )); - } - Self::check_text_len(format!("slides[{i}].title"), &slide.title)?; - if let Some(body) = slide.body.as_deref() { - Self::check_text_len(format!("slides[{i}].body"), body)?; - } - if slide.bullets.len() > MAX_BULLETS_PER_SLIDE { - return Err(Error::invalid_input( - format!("slides[{i}].bullets"), - format!("must contain ≤ {MAX_BULLETS_PER_SLIDE} bullets"), - )); - } - for (b, bullet) in slide.bullets.iter().enumerate() { - Self::check_text_len(format!("slides[{i}].bullets[{b}]"), bullet)?; - } - if let Some(notes) = slide.speaker_notes.as_deref() { - Self::check_text_len(format!("slides[{i}].speaker_notes"), notes)?; - } - if slide.images.len() > MAX_IMAGES_PER_SLIDE { - return Err(Error::invalid_input( - format!("slides[{i}].images"), - format!("must contain ≤ {MAX_IMAGES_PER_SLIDE} images"), - )); - } - for (m, image) in slide.images.iter().enumerate() { - Self::check_image(&format!("slides[{i}].images[{m}]"), image)?; - } - } - Ok(()) - } - - /// Reject a text field longer than [`MAX_TEXT_CHARS`] scalar values. - fn check_text_len(field: impl Into, value: &str) -> Result<()> { - if value.chars().count() > MAX_TEXT_CHARS { - return Err(Error::invalid_input( - field, - format!("must be ≤ {MAX_TEXT_CHARS} chars"), - )); - } - Ok(()) - } - - /// Re-derive an image's format and dimensions from its bytes and reject any - /// disagreement with what the spec declares. - /// - /// [`SlideImage::from_bytes`] keeps the fields consistent by construction, - /// but a spec can also arrive as deserialized JSON, where the three fields - /// are independent. A declared format that does not match the bytes yields - /// a part the reader refuses to render, and declared dimensions that do not - /// match distort the image silently — both are worth a named rejection. - fn check_image(field: &str, image: &SlideImage) -> Result<()> { - if image.bytes.is_empty() { - return Err(Error::invalid_input( - format!("{field}.bytes"), - "must not be empty", - )); - } - if image.bytes.len() > MAX_IMAGE_BYTES { - return Err(Error::invalid_input( - format!("{field}.bytes"), - format!("must be ≤ {MAX_IMAGE_BYTES} bytes"), - )); - } - let sniffed = ImageFormat::sniff(&image.bytes).ok_or_else(|| { - Error::invalid_input(format!("{field}.bytes"), "must be a PNG or JPEG image") - })?; - if sniffed != image.format { - return Err(Error::invalid_input( - format!("{field}.format"), - format!("declared {} but the bytes are {sniffed}", image.format), - )); - } - let (width_px, height_px) = sniffed.dimensions(&image.bytes).ok_or_else(|| { - Error::invalid_input( - format!("{field}.bytes"), - format!("{sniffed} header is truncated or malformed"), - ) - })?; - if (width_px, height_px) != (image.width_px, image.height_px) { - return Err(Error::invalid_input( - format!("{field}.width_px"), - format!( - "declared {}x{} but the bytes are {width_px}x{height_px}", - image.width_px, image.height_px - ), - )); - } - if let Some(caption) = image.caption.as_deref() { - Self::check_text_len(format!("{field}.caption"), caption)?; - } - Ok(()) - } -} - -pub mod wire; - -#[cfg(test)] -mod test; diff --git a/src/openhuman/tools/impl/document/format/spec/presentation/test.rs b/src/openhuman/tools/impl/document/format/spec/presentation/test.rs deleted file mode 100644 index 9dcfef7fd3..0000000000 --- a/src/openhuman/tools/impl/document/format/spec/presentation/test.rs +++ /dev/null @@ -1,367 +0,0 @@ -//! Unit tests for the presentation wire contract. -//! -//! Format-independent, like the spec itself: these must pass in a build with -//! every format feature off. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::{ - PresentationSpec, SlideImage, SlideSpec, MAX_BULLETS_PER_SLIDE, MAX_IMAGES_PER_DECK, - MAX_IMAGES_PER_SLIDE, MAX_IMAGE_BYTES, MAX_SLIDES, MAX_TEXT_CHARS, -}; -use crate::openhuman::tools::implementations::document::format::spec::image::test::{jpeg, png}; -use crate::openhuman::tools::implementations::document::format::spec::image::ImageFormat; -use crate::openhuman::tools::implementations::document::format::Error; - -/// One valid slide carrying a title, a body, and a bullet. -fn slide() -> SlideSpec { - SlideSpec { - title: "Overview".to_string(), - body: Some("The situation so far.".to_string()), - bullets: vec!["A bullet".to_string()], - speaker_notes: Some("Keep it short.".to_string()), - images: vec![], - } -} - -/// A minimal valid spec; each test mutates one field to drive a single branch. -fn spec() -> PresentationSpec { - PresentationSpec { - title: "Quarterly Review".to_string(), - author: Some("Alice".to_string()), - theme: Some("plain".to_string()), - slides: vec![slide()], - } -} - -/// A valid image built from real header bytes. -fn image() -> SlideImage { - SlideImage::from_bytes(png(320, 200), Some("A chart".to_string())).expect("valid png") -} - -/// Assert `spec` is rejected with an `InvalidInput` naming `field`. -fn assert_rejects(spec: &PresentationSpec, field: &str) { - match spec.validate() { - Err(Error::InvalidInput { field: f, .. }) => { - assert_eq!(f, field, "unexpected rejected field"); - } - other => panic!("expected InvalidInput({field}), got {other:?}"), - } -} - -#[test] -fn accepts_a_well_formed_spec() { - assert!(spec().validate().is_ok()); -} - -#[test] -fn accepts_a_spec_with_images() { - let mut s = spec(); - s.slides[0].images = vec![image()]; - assert!(s.validate().is_ok()); -} - -#[test] -fn rejects_a_blank_deck_title() { - let mut s = spec(); - s.title = " ".to_string(); - assert_rejects(&s, "title"); -} - -#[test] -fn rejects_over_long_deck_level_text() { - for (field, mutate) in [("title", 0), ("author", 1), ("theme", 2)] { - let mut s = spec(); - let long = "x".repeat(MAX_TEXT_CHARS + 1); - match mutate { - 0 => s.title = long, - 1 => s.author = Some(long), - _ => s.theme = Some(long), - } - assert_rejects(&s, field); - } -} - -#[test] -fn rejects_a_spec_with_no_slides() { - let mut s = spec(); - s.slides.clear(); - assert_rejects(&s, "slides"); -} - -#[test] -fn rejects_too_many_slides() { - let mut s = spec(); - s.slides = vec![slide(); MAX_SLIDES + 1]; - assert_rejects(&s, "slides"); -} - -#[test] -fn rejects_a_textless_slide() { - // Every text entry is present but whitespace-only, so synthesis would drop - // all of them and render an unlabelled slide. - let mut s = spec(); - s.slides = vec![SlideSpec { - title: " ".to_string(), - body: Some("\t".to_string()), - bullets: vec![String::new()], - speaker_notes: None, - images: vec![], - }]; - assert_rejects(&s, "slides[0]"); -} - -#[test] -fn rejects_a_slide_carrying_only_an_image() { - // Images do not satisfy the "must have text" rule: an unlabelled slide - // reads as a rendering bug rather than a design choice. - let mut s = spec(); - s.slides = vec![SlideSpec { - title: String::new(), - body: None, - bullets: vec![], - speaker_notes: None, - images: vec![image()], - }]; - assert_rejects(&s, "slides[0]"); -} - -#[test] -fn rejects_over_long_slide_text_naming_its_index() { - let long = || "x".repeat(MAX_TEXT_CHARS + 1); - - let mut s = spec(); - s.slides.push(SlideSpec { - title: long(), - ..slide() - }); - assert_rejects(&s, "slides[1].title"); - - let mut s = spec(); - s.slides[0].body = Some(long()); - assert_rejects(&s, "slides[0].body"); - - let mut s = spec(); - s.slides[0].bullets = vec!["ok".to_string(), long()]; - assert_rejects(&s, "slides[0].bullets[1]"); - - let mut s = spec(); - s.slides[0].speaker_notes = Some(long()); - assert_rejects(&s, "slides[0].speaker_notes"); -} - -#[test] -fn rejects_too_many_bullets() { - let mut s = spec(); - s.slides[0].bullets = vec!["b".to_string(); MAX_BULLETS_PER_SLIDE + 1]; - assert_rejects(&s, "slides[0].bullets"); -} - -#[test] -fn rejects_too_many_images_on_one_slide() { - let mut s = spec(); - s.slides[0].images = vec![image(); MAX_IMAGES_PER_SLIDE + 1]; - assert_rejects(&s, "slides[0].images"); -} - -#[test] -fn rejects_too_many_images_across_the_deck() { - // Each slide is within the per-slide cap; only the deck total is not. The - // per-slide cap bounds readability, the deck cap bounds the media payload. - let per_slide = MAX_IMAGES_PER_SLIDE; - let slides_needed = MAX_IMAGES_PER_DECK / per_slide + 1; - let mut s = spec(); - s.slides = vec![ - SlideSpec { - images: vec![image(); per_slide], - ..slide() - }; - slides_needed - ]; - assert!(s.image_count() > MAX_IMAGES_PER_DECK); - assert_rejects(&s, "slides[].images"); -} - -#[test] -fn image_count_sums_across_slides() { - let mut s = spec(); - s.slides = vec![ - SlideSpec { - images: vec![image(), image()], - ..slide() - }, - SlideSpec { - images: vec![image()], - ..slide() - }, - ]; - assert_eq!(s.image_count(), 3); -} - -#[test] -fn rejects_an_over_long_image_caption() { - let mut s = spec(); - let mut img = image(); - img.caption = Some("c".repeat(MAX_TEXT_CHARS + 1)); - s.slides[0].images = vec![img]; - assert_rejects(&s, "slides[0].images[0].caption"); -} - -#[test] -fn from_bytes_derives_format_and_dimensions() { - let img = SlideImage::from_bytes(png(1920, 1080), None).expect("valid png"); - assert_eq!(img.format, ImageFormat::Png); - assert_eq!((img.width_px, img.height_px), (1920, 1080)); - assert_eq!(img.caption, None); - - let img = SlideImage::from_bytes(jpeg(640, 480), Some("j".to_string())).expect("valid jpeg"); - assert_eq!(img.format, ImageFormat::Jpeg); - assert_eq!((img.width_px, img.height_px), (640, 480)); -} - -#[test] -fn from_bytes_rejects_bad_input() { - assert!(matches!( - SlideImage::from_bytes(vec![], None), - Err(Error::InvalidInput { .. }) - )); - assert!(matches!( - SlideImage::from_bytes(b"not an image".to_vec(), None), - Err(Error::InvalidInput { .. }) - )); - // PNG signature with a truncated IHDR: the right format, unmeasurable. - assert!(matches!( - SlideImage::from_bytes(vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], None), - Err(Error::InvalidInput { .. }) - )); -} - -#[test] -fn from_bytes_rejects_an_oversize_image() { - // A real PNG header followed by enough filler to cross the cap, so the - // rejection is the size check rather than the sniff. - let mut bytes = png(8, 8); - bytes.resize(MAX_IMAGE_BYTES + 1, 0); - assert!(matches!( - SlideImage::from_bytes(bytes, None), - Err(Error::InvalidInput { .. }) - )); -} - -#[test] -fn validate_rejects_an_image_whose_declared_format_contradicts_its_bytes() { - // `from_bytes` cannot produce this, but deserialized JSON can: the three - // fields are independent on the wire. A wrong format yields a part the - // reader refuses to render, so it is worth a named rejection. - let mut s = spec(); - let mut img = image(); - img.format = ImageFormat::Jpeg; - s.slides[0].images = vec![img]; - assert_rejects(&s, "slides[0].images[0].format"); -} - -#[test] -fn validate_rejects_an_image_whose_declared_dimensions_contradict_its_bytes() { - // Declared dimensions that disagree with the bytes distort the image - // silently, which is worse than failing. - let mut s = spec(); - let mut img = image(); - img.width_px += 1; - s.slides[0].images = vec![img]; - assert_rejects(&s, "slides[0].images[0].width_px"); -} - -#[test] -fn validate_rejects_empty_oversize_and_unrecognised_image_bytes() { - let mut s = spec(); - let mut img = image(); - img.bytes.clear(); - s.slides[0].images = vec![img]; - assert_rejects(&s, "slides[0].images[0].bytes"); - - let mut s = spec(); - let mut img = image(); - img.bytes = b"not an image".to_vec(); - s.slides[0].images = vec![img]; - assert_rejects(&s, "slides[0].images[0].bytes"); - - let mut s = spec(); - let mut img = image(); - img.bytes.resize(MAX_IMAGE_BYTES + 1, 0); - s.slides[0].images = vec![img]; - assert_rejects(&s, "slides[0].images[0].bytes"); -} - -#[test] -fn validate_rejects_an_image_with_an_unmeasurable_header() { - // Sniffs as PNG, but the IHDR is gone — measurement fails after the format - // check has already passed, which is a distinct branch. - let mut s = spec(); - let mut img = image(); - img.bytes.truncate(8); - s.slides[0].images = vec![img]; - assert_rejects(&s, "slides[0].images[0].bytes"); -} - -#[test] -fn is_textless_reflects_text_presence() { - assert!(!slide().is_textless()); - assert!(SlideSpec { - title: String::new(), - body: None, - bullets: vec![], - speaker_notes: None, - images: vec![], - } - .is_textless()); - // A title alone is enough. - assert!(!SlideSpec { - title: "Only a title".to_string(), - body: None, - bullets: vec![], - speaker_notes: None, - images: vec![], - } - .is_textless()); - // So is a body alone, or a bullet alone. - assert!(!SlideSpec { - title: String::new(), - body: Some("Body".to_string()), - bullets: vec![], - speaker_notes: None, - images: vec![], - } - .is_textless()); - assert!(!SlideSpec { - title: String::new(), - body: None, - bullets: vec!["Bullet".to_string()], - speaker_notes: None, - images: vec![], - } - .is_textless()); -} - -#[test] -fn spec_round_trips_through_json() { - let mut s = spec(); - s.slides[0].images = vec![image()]; - let json = serde_json::to_string(&s).expect("serialises"); - let back: PresentationSpec = serde_json::from_str(&json).expect("deserialises"); - assert_eq!(back, s); - assert!(back.validate().is_ok()); -} - -#[test] -fn spec_rejects_unknown_json_fields() { - let json = r#"{"title":"T","slides":[],"tilte":"typo"}"#; - assert!(serde_json::from_str::(json).is_err()); -} - -#[test] -fn spec_defaults_optional_fields() { - let s: PresentationSpec = serde_json::from_str(r#"{"title":"T"}"#).expect("deserialises"); - assert_eq!(s.author, None); - assert_eq!(s.theme, None); - assert!(s.slides.is_empty()); -} diff --git a/src/openhuman/tools/impl/document/format/spec/presentation/wire.rs b/src/openhuman/tools/impl/document/format/spec/presentation/wire.rs deleted file mode 100644 index ef9cb8504c..0000000000 --- a/src/openhuman/tools/impl/document/format/spec/presentation/wire.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! The presentation spec as it crosses a bus, where bytes cannot travel inline. -//! -//! A `TinyBus` frame is a 16 MiB JSON document and a deck may legally carry -//! 40 MiB of images, so image bytes ride a stream beside the call rather than -//! inside it. A call has one stream and a deck has many images, so the images -//! are concatenated in slide order and each one declares its `byte_len`; the -//! module splits them apart and resolves each into a real -//! [`super::SlideImage`] — bytes, format and dimensions. -//! -//! The lengths live in the spec rather than in the stream because they are what -//! makes a truncated or over-long transfer a named rejection instead of a deck -//! with a picture assembled from two different images. -//! -//! Only the presentation spec needs this treatment. A document spec is text and -//! its aggregate cap keeps it inside a frame, so a document crosses unchanged. -//! -//! Defined here rather than in the module that serves it so a host driving that -//! module over a bus shares one definition of the shape instead of re-declaring -//! it. Like the rest of [`crate::openhuman::tools::implementations::document::format::spec`] it is serde and nothing else. - -use serde::{Deserialize, Serialize}; - -/// A slide image, as it appears on the bus: one byte range of the concatenated -/// image stream that travels beside the call. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WireSlideImage { - /// Length of this image's bytes within the concatenated image stream. - pub byte_len: u64, - /// Optional caption, rendered as a bullet beneath the image. - #[serde(default)] - pub caption: Option, -} - -/// One content slide, as it appears on the bus. -/// -/// Identical to [`super::SlideSpec`] apart from `images`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WireSlideSpec { - /// Slide title. - #[serde(default)] - pub title: String, - /// Body text, rendered above the bullets. - #[serde(default)] - pub body: Option, - /// Bullets, rendered after the body text. - #[serde(default)] - pub bullets: Vec, - /// Speaker notes attached to the slide. - #[serde(default)] - pub speaker_notes: Option, - /// Images, each naming a staged blob. - #[serde(default)] - pub images: Vec, -} - -/// A deck, as it appears on the bus. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WirePresentationSpec { - /// Deck title, rendered on a leading title slide. - pub title: String, - /// Optional author byline. - #[serde(default)] - pub author: Option, - /// Optional theme hint. - #[serde(default)] - pub theme: Option, - /// Content slides, in display order. - #[serde(default)] - pub slides: Vec, -} diff --git a/src/openhuman/tools/impl/document/mod.rs b/src/openhuman/tools/impl/document/mod.rs index a221fc01ef..8e9f1f3fae 100644 --- a/src/openhuman/tools/impl/document/mod.rs +++ b/src/openhuman/tools/impl/document/mod.rs @@ -39,7 +39,19 @@ use crate::openhuman::security::SecurityPolicy; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; mod engine; -pub(crate) mod format; +/// The document wire contract, shared with the `tinydocs` module. +/// +/// This was 1,873 lines of this repository — `format/error/`, `format/spec/` — +/// and every line of it also existed in `crates/tinydocs-bus/src/` upstream, +/// differing only in the paths inside doc links. Two definitions of a contract +/// is the drift risk the contract exists to remove: the specs here are what an +/// LLM is shown as a JSON tool schema and what the module validates against, +/// so a limit that moved on one side would become a tool description promising +/// something the module does not enforce. +/// +/// Aliased rather than re-exported item by item so the ~30 existing +/// `…::document::format::…` paths keep resolving unchanged. +pub(crate) use tinydocs_bus as format; mod types; #[cfg(test)] diff --git a/src/openhuman/voice/always_on.rs b/src/openhuman/voice/always_on.rs index 4631b4f331..27097768fe 100644 --- a/src/openhuman/voice/always_on.rs +++ b/src/openhuman/voice/always_on.rs @@ -122,7 +122,7 @@ pub async fn start_if_enabled(app_config: &Config) { return; } - let vad = tinyvoice::VadConfig::from_server_config(&app_config.voice_server); + let vad = tinyvoice::vad_config_from_server_config(&app_config.voice_server); let config = app_config.clone(); log::info!( "{LOG_PREFIX} enabled — onset={:.4} hangover={}ms min_speech={}ms max_utt={}ms", @@ -340,9 +340,10 @@ pub async fn start_if_enabled(app_config: &Config) { // the segmenter reported so an utterance carries exactly the // samples it was measured from. let mut cursor = 0usize; - for event in events { - match event { - tinyvoice::VadEvent::SpeechStart { frame } => { + for indexed in events { + let frame = indexed.frame; + match indexed.event { + tinyvoice::VadEvent::SpeechStart => { let at = frame * FRAME_SAMPLES; log::info!( "{LOG_PREFIX} speech onset rms={:.4} (onset={onset_threshold:.4})", @@ -353,10 +354,7 @@ pub async fn start_if_enabled(app_config: &Config) { notch_status("Listening", 2500); // pill: capturing speech } tinyvoice::VadEvent::SpeechEnd { - frame, - emit, - voiced_ms, - .. + emit, voiced_ms, .. } => { let upto = ((frame + 1) * FRAME_SAMPLES).min(frames.len()); if upto > cursor && utterance.len() < MAX_UTTERANCE_SAMPLES { @@ -911,7 +909,7 @@ mod tests { c.vad_max_utterance_secs = 2.5; c.vad_hangover_ms = 750; - let v = tinyvoice::VadConfig::from_server_config(&c); + let v = tinyvoice::vad_config_from_server_config(&c); assert_eq!(v.max_utterance_ms, 2500, "seconds become milliseconds"); assert_eq!(v.hangover_ms, 750, "milliseconds pass through"); @@ -925,13 +923,13 @@ mod tests { let mut c = crate::openhuman::config::VoiceServerConfig::default(); c.vad_max_utterance_secs = 0.0; assert_eq!( - tinyvoice::VadConfig::from_server_config(&c).max_utterance_ms, + tinyvoice::vad_config_from_server_config(&c).max_utterance_ms, 1 ); c.vad_max_utterance_secs = -5.0; assert_eq!( - tinyvoice::VadConfig::from_server_config(&c).max_utterance_ms, + tinyvoice::vad_config_from_server_config(&c).max_utterance_ms, 1 ); } diff --git a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs index cf6c4be0d6..4dc60f1abf 100644 --- a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs @@ -139,17 +139,22 @@ use openhuman_core::openhuman::memory::{ // stamps provenance from an explicit taint argument. use openhuman_core::openhuman::memory::api::provider::MemoryCore; use openhuman_core::openhuman::memory::api::types::MemoryTaint; +// These request/record types are consumed directly by `openhuman_core::openhuman::memory::ops` +// and `openhuman::threads::ops` handlers below, which take the host's own `rpc_models` types, +// not the engine crate's same-named ones — so they must come from the host, not `tinymemory_core`. +use openhuman_core::openhuman::memory::rpc_models::{ + AppendConversationMessageRequest, ConversationMessageRecord, ConversationMessagesRequest, + CreateConversationThreadRequest, DeleteConversationThreadRequest, DeleteDocumentRequest, + EmptyRequest, GenerateConversationThreadTitleRequest, ListDocumentsRequest, + ListMemoryFilesRequest, MemoryInitRequest, ReadMemoryFileRequest, + UpdateConversationMessageRequest, UpdateConversationThreadLabelsRequest, + UpdateConversationThreadTitleRequest, UpsertConversationThreadRequest, WriteMemoryFileRequest, +}; use tinymemory_core::{ remember::RememberSourceKind, rpc_models::{ - ApiEnvelope, ApiError, ApiMeta, AppendConversationMessageRequest, - ConversationMessageRecord, ConversationMessagesRequest, CreateConversationThreadRequest, - DeleteConversationThreadRequest, DeleteDocumentRequest, EmptyRequest, - GenerateConversationThreadTitleRequest, ListDocumentsRequest, ListMemoryFilesRequest, - MemoryInitRequest, PaginationMeta, QueryNamespaceRequest, ReadMemoryFileRequest, - RecallContextRequest, RecallMemoriesRequest, UpdateConversationMessageRequest, - UpdateConversationThreadLabelsRequest, UpdateConversationThreadTitleRequest, - UpsertConversationThreadRequest, WriteMemoryFileRequest, + ApiEnvelope, ApiError, ApiMeta, PaginationMeta, QueryNamespaceRequest, + RecallContextRequest, RecallMemoriesRequest, }, traits::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts}, util::redact::{redact, redact_endpoint}, diff --git a/vendor/tinydocs b/vendor/tinydocs new file mode 160000 index 0000000000..d1323fcc5b --- /dev/null +++ b/vendor/tinydocs @@ -0,0 +1 @@ +Subproject commit d1323fcc5b5aedd2f2823b542f4cb33d13c6bbd0 diff --git a/vendor/tinyjuice b/vendor/tinyjuice new file mode 160000 index 0000000000..0c7f828169 --- /dev/null +++ b/vendor/tinyjuice @@ -0,0 +1 @@ +Subproject commit 0c7f828169f626c252ca752ce7c239dfa4c28bb4 diff --git a/vendor/tinyruntime b/vendor/tinyruntime index cf67fd38f0..8ef0ca0646 160000 --- a/vendor/tinyruntime +++ b/vendor/tinyruntime @@ -1 +1 @@ -Subproject commit cf67fd38f039767cc40814f9b09d6956aee93ad9 +Subproject commit 8ef0ca0646934295130895736325d28a986c79aa diff --git a/vendor/tinyvoice b/vendor/tinyvoice new file mode 160000 index 0000000000..a1e76a2e27 --- /dev/null +++ b/vendor/tinyvoice @@ -0,0 +1 @@ +Subproject commit a1e76a2e27b93a616b3aac5f0a8df653869066d0