From f6acdde4e0dbeb9cc0ba74ab8454d5156e2e4762 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 11:48:01 +0300 Subject: [PATCH 01/42] feat(vendor): add tinydocs and tinyvoice submodules Register the tinydocs and tinyvoice repositories as git submodules under vendor, pinning them to specific commits to make the documentation and voice capabilities available as versioned dependencies. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .gitmodules | 8 ++++++++ vendor/tinydocs | 1 + vendor/tinyvoice | 1 + 3 files changed, 10 insertions(+) create mode 160000 vendor/tinydocs create mode 160000 vendor/tinyvoice diff --git a/.gitmodules b/.gitmodules index 82b93c915e..155c98d010 100644 --- a/.gitmodules +++ b/.gitmodules @@ -41,3 +41,11 @@ [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 diff --git a/vendor/tinydocs b/vendor/tinydocs new file mode 160000 index 0000000000..d17f7e3ba3 --- /dev/null +++ b/vendor/tinydocs @@ -0,0 +1 @@ +Subproject commit d17f7e3ba3bb81dc781e3554d142a996792adce5 diff --git a/vendor/tinyvoice b/vendor/tinyvoice new file mode 160000 index 0000000000..15bee2d652 --- /dev/null +++ b/vendor/tinyvoice @@ -0,0 +1 @@ +Subproject commit 15bee2d65216ea29234dc20910c6de647e7defa3 From c8a27e20f8e85c87991b1cdadfb0bbdcdc4930c1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 11:48:27 +0300 Subject: [PATCH 02/42] feat(deps): add tinydocs-bus and tinyvoice-bus as optional path dependencies The crate now depends on `tinydocs-bus` and `tinyvoice-bus` instead of carrying verbatim copies of their contract types, eliminating the drift risk of two definitions that previously existed in the document format tool implementation and the voice module. Both dependencies are optional and gated behind their respective features, matching the existing pattern for vendored path dependencies. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.toml | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index be8a2607d6..e06aeced21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -475,24 +475,43 @@ 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. +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 default-ON `voice` feature. +tinyvoice-bus = { path = "vendor/tinyvoice/crates/tinyvoice-bus", optional = true } # 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 From f0e18d2bc64e5134647d56f64ac37053c03e9b3d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 11:48:38 +0300 Subject: [PATCH 03/42] feat(documents): add tinydocs-bus dependency to documents feature The documents feature now depends on tinydocs-bus, which provides the member names and payload types that the module answers with. This is a contract-only dependency requiring serde and nothing else. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index e06aeced21..4c8cf8dd96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -778,7 +778,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, @@ -833,6 +833,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", From ce4c1a42cafc1b34c7c3d0e3a320be89b1e1a3ee Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 11:48:49 +0300 Subject: [PATCH 04/42] chore: remove unused document format module Removed the entire document format module, including its error types, spec types, and all associated tests, as this functionality is no longer needed. The module provided document synthesis and text extraction capabilities that have been superseded by other components in the codebase. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tools/impl/document/format/error/mod.rs | 120 ------ .../tools/impl/document/format/error/test.rs | 54 --- .../tools/impl/document/format/mod.rs | 29 -- .../impl/document/format/spec/document/mod.rs | 261 ------------- .../document/format/spec/document/test.rs | 272 ------------- .../impl/document/format/spec/image/mod.rs | 161 -------- .../impl/document/format/spec/image/test.rs | 151 ------- .../tools/impl/document/format/spec/mod.rs | 45 --- .../document/format/spec/presentation/mod.rs | 340 ---------------- .../document/format/spec/presentation/test.rs | 367 ------------------ .../document/format/spec/presentation/wire.rs | 73 ---- src/openhuman/tools/impl/document/mod.rs | 14 +- 12 files changed, 13 insertions(+), 1874 deletions(-) delete mode 100644 src/openhuman/tools/impl/document/format/error/mod.rs delete mode 100644 src/openhuman/tools/impl/document/format/error/test.rs delete mode 100644 src/openhuman/tools/impl/document/format/mod.rs delete mode 100644 src/openhuman/tools/impl/document/format/spec/document/mod.rs delete mode 100644 src/openhuman/tools/impl/document/format/spec/document/test.rs delete mode 100644 src/openhuman/tools/impl/document/format/spec/image/mod.rs delete mode 100644 src/openhuman/tools/impl/document/format/spec/image/test.rs delete mode 100644 src/openhuman/tools/impl/document/format/spec/mod.rs delete mode 100644 src/openhuman/tools/impl/document/format/spec/presentation/mod.rs delete mode 100644 src/openhuman/tools/impl/document/format/spec/presentation/test.rs delete mode 100644 src/openhuman/tools/impl/document/format/spec/presentation/wire.rs 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)] From a78d00c5f2c36863db38e05e63f9568212707a43 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 11:50:54 +0300 Subject: [PATCH 05/42] chore(deps): add tinydocs-bus and tinyvoice-bus dependencies Two new workspace crates, tinydocs-bus and tinyvoice-bus, have been added to the Cargo.lock file to support upcoming documentation and voice bus functionality. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index f409adc6e1..1c53509672 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4181,6 +4181,7 @@ dependencies = [ "tinychannels", "tinycortex", "tinycortex-api", + "tinydocs-bus", "tinyflows", "tinyhosts", "tinyhumans-sdk", @@ -4192,6 +4193,7 @@ dependencies = [ "tinymemory-tinycortex", "tinyplace", "tinyruntime-bus", + "tinyvoice-bus", "tinywallet", "tokio", "tokio-stream", @@ -6454,6 +6456,14 @@ dependencies = [ "tinymemory-api", ] +[[package]] +name = "tinydocs-bus" +version = "0.1.13" +dependencies = [ + "serde", + "thiserror 2.0.18", +] + [[package]] name = "tinyflows" version = "0.8.0" @@ -6720,6 +6730,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.4.0" From 625bba7b05e3801ecadb966c88e5bc99de0a5d18 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 11:53:07 +0300 Subject: [PATCH 06/42] refactor(voice): replace local type definitions with upstream contract types Remove the locally defined `HallucinationMode` and `VoiceIntent` enums and their associated methods, replacing them with re-exports from the `tinyvoice-bus` crate. The module now depends on `tinyvoice-bus`, making the previous workaround unnecessary. The `clamped` function is moved to a free function to keep host policy separate from the contract type, and the wire value helper is renamed to `hallucination_mode_wire` for clarity. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/voice.rs | 151 ++++++++++----------------------- 1 file changed, 47 insertions(+), 104 deletions(-) diff --git a/src/openhuman/modules/voice.rs b/src/openhuman/modules/voice.rs index f641a4ab0b..baaf4780bb 100644 --- a/src/openhuman/modules/voice.rs +++ b/src/openhuman/modules/voice.rs @@ -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. /// @@ -163,25 +102,29 @@ pub async fn route(config: &Config, transcript: &str) -> Result 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, } } @@ -239,7 +182,7 @@ pub async fn is_hallucinated( text: &str, mode: HallucinationMode, ) -> Result { - call(config, "IsHallucinated", (text, mode.as_wire())).await + call(config, "IsHallucinated", (text, hallucination_mode_wire(mode))).await } /// Downmix, resample to 16 kHz, optionally silence-gate, and frame as WAV. From bcf87d85fe967e50885b144f0ed4631a7361861a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 11:53:23 +0300 Subject: [PATCH 07/42] refactor(voice): replace local VAD types with re-exports from tinyvoice-bus Remove the locally defined `VadConfig` struct and `VadEvent` enum, replacing them with re-exports from the `tinyvoice-bus` crate. The `from_server_config` constructor becomes a free function `vad_config_from_server_config` because the type now belongs to the contract crate. The `VadEvent` enum is split into `VadEvent` and `IndexedVadEvent` to keep the frame index out of the event itself, while preserving the same JSON wire format. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/voice.rs | 83 +++++++++++++--------------------- 1 file changed, 31 insertions(+), 52 deletions(-) diff --git a/src/openhuman/modules/voice.rs b/src/openhuman/modules/voice.rs index baaf4780bb..a9dae0ec42 100644 --- a/src/openhuman/modules/voice.rs +++ b/src/openhuman/modules/voice.rs @@ -230,61 +230,40 @@ pub async fn encode_wav( 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, -} +/// 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; -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, - } +/// 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. /// @@ -324,7 +303,7 @@ impl VadSession { config: &Config, frame_ms: u32, energies: &[f32], - ) -> Result, VoiceCallError> { + ) -> Result, VoiceCallError> { let json: String = call(config, "VadPush", (self.id, frame_ms, energies)).await?; serde_json::from_str(&json) .map_err(|e| VoiceCallError::Failed(format!("could not decode VAD events: {e}"))) From df542343497e4f1f49697a7207e74737c8c65f3c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 11:53:35 +0300 Subject: [PATCH 08/42] refactor(voice): replace associated function with free function for VAD config The `VadConfig::from_server_config` associated function has been replaced with a standalone `vad_config_from_server_config` function, and the event loop now destructures indexed events to access the frame and event separately. This change aligns with the updated tinyvoice API that separates frame indexing from event data. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/voice/always_on.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) 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 ); } From 1836e4a495c818bc68c9259b228675cb26d8fa0c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 11:53:49 +0300 Subject: [PATCH 09/42] refactor(voice): replace inline method name strings with constants Replace hardcoded method name strings with constants from the `tinyvoice_bus::names::methods` module to centralize method name definitions and reduce duplication across the voice module. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/voice.rs | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/openhuman/modules/voice.rs b/src/openhuman/modules/voice.rs index a9dae0ec42..32c2d0ef74 100644 --- a/src/openhuman/modules/voice.rs +++ b/src/openhuman/modules/voice.rs @@ -30,6 +30,7 @@ //! guess: see [`is_hallucinated`]. use serde::Deserialize; +use tinyvoice_bus::names::methods; use super::{host, ops, registry}; use crate::openhuman::config::Config; @@ -99,7 +100,7 @@ pub use tinyvoice_bus::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(clamped(intent)) @@ -142,7 +143,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 { @@ -163,7 +164,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. @@ -182,7 +183,7 @@ pub async fn is_hallucinated( text: &str, mode: HallucinationMode, ) -> Result { - call(config, "IsHallucinated", (text, hallucination_mode_wire(mode))).await + call(config, methods::IS_HALLUCINATED, (text, hallucination_mode_wire(mode))).await } /// Downmix, resample to 16 kHz, optionally silence-gate, and frame as WAV. @@ -208,7 +209,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?; @@ -226,7 +227,7 @@ 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) } @@ -286,7 +287,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 }) } @@ -304,7 +305,7 @@ impl VadSession { frame_ms: u32, energies: &[f32], ) -> Result, VoiceCallError> { - let json: String = call(config, "VadPush", (self.id, frame_ms, energies)).await?; + 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}"))) } @@ -316,7 +317,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. @@ -329,7 +330,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. @@ -338,7 +339,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 } } @@ -359,7 +360,7 @@ pub async fn prepare_frames( ) -> Result, VoiceCallError> { let encoded: String = call( config, - "PrepareFrames", + methods::PREPARE_FRAMES, (encode_samples(samples), source_rate, channels), ) .await?; @@ -378,7 +379,7 @@ pub async fn frame_energies( ) -> Result, VoiceCallError> { call( config, - "FrameEnergies", + methods::FRAME_ENERGIES, (encode_samples(samples), frame_len), ) .await @@ -402,7 +403,7 @@ 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) } From 0c0daee4480f4320d7b254016de63564c2fc206e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 11:56:06 +0300 Subject: [PATCH 10/42] fix(voice): remove unused serde import The `serde::Deserialize` import was no longer used in the voice module and has been removed to keep the codebase clean and avoid compiler warnings. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/voice.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/openhuman/modules/voice.rs b/src/openhuman/modules/voice.rs index 32c2d0ef74..dd5d229a71 100644 --- a/src/openhuman/modules/voice.rs +++ b/src/openhuman/modules/voice.rs @@ -29,7 +29,6 @@ //! 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}; From 18a1d4c9ec25b0d6f42fef7e53cba8bdd779dce2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 11:57:03 +0300 Subject: [PATCH 11/42] test(voice): use public helpers instead of private methods in tests Replace calls to private methods `as_wire()` and `clamped()` with the public helper functions `hallucination_mode_wire()` and `clamped()` that they wrap, and update the registry test to compare against the `tinyvoice_bus` crate constants instead of hardcoded strings. This makes the tests exercise the same public API that production code uses, catching any future divergence between the helpers and the methods they delegate to. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/voice_tests.rs | 29 +++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/openhuman/modules/voice_tests.rs b/src/openhuman/modules/voice_tests.rs index b5d5b0601a..78638ca571 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,11 @@ 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 +144,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" @@ -314,7 +321,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 +329,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); } } From 06904fb27b5bdd524d4d77e81c8f2acde81101be Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 11:57:27 +0300 Subject: [PATCH 12/42] feat(registry): update tinyruntime modules to version 0.2.2 with platform assets Bump the version of the TINYRUNTIME, TINYRUNTIME_NODEJS, and TINYRUNTIME_PYTHON module records from 0.1.0 to 0.2.2 and populate their previously empty assets arrays with platform-specific download archives and SHA-256 checksums for Ubuntu, macOS, and Windows on multiple architectures. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/registry.rs | 186 ++++++++++++++++++++++++++++-- 1 file changed, 177 insertions(+), 9 deletions(-) diff --git a/src/openhuman/modules/registry.rs b/src/openhuman/modules/registry.rs index 28ac510513..bd3f4b5e5b 100644 --- a/src/openhuman/modules/registry.rs +++ b/src/openhuman/modules/registry.rs @@ -447,9 +447,65 @@ const TINYRUNTIME: ModuleRecord = ModuleRecord { 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, }; @@ -472,9 +528,65 @@ const TINYRUNTIME_NODEJS: ModuleRecord = ModuleRecord { 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, }; @@ -489,9 +601,65 @@ const TINYRUNTIME_PYTHON: ModuleRecord = ModuleRecord { 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, }; From 3031666a921116e7918979c940804dd86e4fbe20 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 11:57:41 +0300 Subject: [PATCH 13/42] docs(registry): document pinned v0.2.2 assets for tinyruntime modules Replace the placeholder documentation that explained why the tinyruntime, tinyruntime-nodejs, and tinyruntime-python module records carried no pinned assets with the actual v0.2.2 release digests, and update the cross-references accordingly so the documentation reflects the current state of the code. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/registry.rs | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/openhuman/modules/registry.rs b/src/openhuman/modules/registry.rs index bd3f4b5e5b..6a116fc440 100644 --- a/src/openhuman/modules/registry.rs +++ b/src/openhuman/modules/registry.rs @@ -431,17 +431,11 @@ 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", @@ -522,7 +516,7 @@ 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", @@ -595,7 +589,7 @@ 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", From 10846793dbb6fec65e33e33760aa6bcd5bb9bd2f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:00:22 +0300 Subject: [PATCH 14/42] refactor(documents): replace inline method names with constants from tinydocs_bus Replace hardcoded string literals for RPC method names with the corresponding constants from the `tinydocs_bus::names::methods` module. This centralizes method name definitions, reducing the risk of typos and making future renames easier to manage. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/documents.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 From 24d5afb4e88124e7b4cded9619b2160dbf50f0a6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:00:41 +0300 Subject: [PATCH 15/42] test(documents): add registry and contract coverage tests Add two tests that verify the documents module's registry entry matches the tinydocs bus contract and that every method the client calls is declared by the contract, preventing silent runtime failures from mismatched bus names or missing members. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/documents_tests.rs | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) 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" + ); + } +} From 3eec58e6a98e94cefa6ad813f974b6e9d58841cb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:00:52 +0300 Subject: [PATCH 16/42] test(voice): add test that every declared contract member is called Add a test that verifies the voice module calls every method declared by the tinyvoice_bus contract, ensuring no declared member is left uncalled and that the client stays in sync with the contract. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/voice_tests.rs | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/openhuman/modules/voice_tests.rs b/src/openhuman/modules/voice_tests.rs index 78638ca571..828639a033 100644 --- a/src/openhuman/modules/voice_tests.rs +++ b/src/openhuman/modules/voice_tests.rs @@ -384,3 +384,36 @@ 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, + ]; + for member in tinyvoice_bus::names::METHODS { + assert!( + called.contains(member) || *member == methods::SEGMENT, + "the contract declares `{member}`, which this client never calls" + ); + } +} From c77e6b8034a51bbe6fdc9b326df0af64ecd03761 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:01:03 +0300 Subject: [PATCH 17/42] fix(voice_tests): correct contract assertion for segment method The test that verifies every declared method is called by the client now properly asserts that `Segment` is never called, rather than listing it as an exception in the assertion condition. This change makes the test logic clearer by explicitly skipping the method in the loop and documenting that the omission is intentional because the always-on capture loop uses the stateful `Vad*` session instead of the stateless segmenter. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/voice_tests.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/openhuman/modules/voice_tests.rs b/src/openhuman/modules/voice_tests.rs index 828639a033..aefb796d8f 100644 --- a/src/openhuman/modules/voice_tests.rs +++ b/src/openhuman/modules/voice_tests.rs @@ -410,9 +410,16 @@ fn every_member_this_client_calls_is_one_the_contract_declares() { 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) || *member == methods::SEGMENT, + called.contains(member), "the contract declares `{member}`, which this client never calls" ); } From 8cd80238c19838d1a98b800bd6f13140d2c675c7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:06:59 +0300 Subject: [PATCH 18/42] chore(registry): update tinydocs and tinyvoice module versions Update the module registry entries for tinydocs from 0.1.13 to 0.1.14 and tinyvoice from 0.1.3 to 0.1.5, including the corresponding release URLs, archive filenames, and SHA-256 checksums for all supported platforms. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/registry.rs | 96 +++++++++++++++---------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/src/openhuman/modules/registry.rs b/src/openhuman/modules/registry.rs index 6a116fc440..06292dcce1 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, @@ -359,63 +359,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, From 797e2c1a3d0db6b1579a8b908e6b2b8feb5a0834 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:07:08 +0300 Subject: [PATCH 19/42] chore(deps): update vendor submodules Update the pinned commits for the tinydocs, tinyruntime, and tinyvoice submodules to their latest versions. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinydocs | 2 +- vendor/tinyruntime | 2 +- vendor/tinyvoice | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/vendor/tinydocs b/vendor/tinydocs index d17f7e3ba3..d1323fcc5b 160000 --- a/vendor/tinydocs +++ b/vendor/tinydocs @@ -1 +1 @@ -Subproject commit d17f7e3ba3bb81dc781e3554d142a996792adce5 +Subproject commit d1323fcc5b5aedd2f2823b542f4cb33d13c6bbd0 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 index 15bee2d652..a1e76a2e27 160000 --- a/vendor/tinyvoice +++ b/vendor/tinyvoice @@ -1 +1 @@ -Subproject commit 15bee2d65216ea29234dc20910c6de647e7defa3 +Subproject commit a1e76a2e27b93a616b3aac5f0a8df653869066d0 From 961cb91b60096c5564a8ec17502d8d21d9efbbcd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:07:15 +0300 Subject: [PATCH 20/42] fix(voice_tests): correct function call in degradation test Updated the test `every_entry_point_degrades_rather_than_hanging_when_the_module_is_gone` to call `vad_config_from_server_config` instead of the removed `VadConfig::from_server_config`, fixing a compilation error caused by the API change. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/voice_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openhuman/modules/voice_tests.rs b/src/openhuman/modules/voice_tests.rs index aefb796d8f..ca1153b90e 100644 --- a/src/openhuman/modules/voice_tests.rs +++ b/src/openhuman/modules/voice_tests.rs @@ -364,7 +364,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() ) ) From 5da6e6947976ab47437a95e1db6159fe5f1eac26 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:07:23 +0300 Subject: [PATCH 21/42] fix(voice_tests): update test patterns for IndexedVadEvent The test assertions in `the_published_module_answers_through_this_client` were updated to match the new `IndexedVadEvent` wrapper, which now carries the `VadEvent::SpeechEnd` variant inside an indexed structure. Additionally, a stray ampersand was removed from the `vad_config_from_server_config` call in the degradation test to fix a syntax error. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/voice_tests.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/openhuman/modules/voice_tests.rs b/src/openhuman/modules/voice_tests.rs index ca1153b90e..f9a6335fc0 100644 --- a/src/openhuman/modules/voice_tests.rs +++ b/src/openhuman/modules/voice_tests.rs @@ -298,8 +298,9 @@ 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); @@ -364,7 +365,7 @@ async fn every_entry_point_degrades_rather_than_hanging_when_the_module_is_gone( assert!(matches!( super::VadSession::open( &config, - super::vad_config_from_server_config(& + super::vad_config_from_server_config( &crate::openhuman::config::VoiceServerConfig::default() ) ) From fdd575783e3abb5e18d2b612b41bb0853c4459d4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:07:42 +0300 Subject: [PATCH 22/42] chore(deps): bump tinydocs-bus and tinyruntime-bus versions Bump the version of tinydocs-bus from 0.1.13 to 0.1.14 and tinyruntime-bus from 0.2.1 to 0.2.2 in the lockfile to reflect updated package releases. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c53509672..98f03e0338 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6458,7 +6458,7 @@ dependencies = [ [[package]] name = "tinydocs-bus" -version = "0.1.13" +version = "0.1.14" dependencies = [ "serde", "thiserror 2.0.18", @@ -6690,7 +6690,7 @@ dependencies = [ [[package]] name = "tinyruntime-bus" -version = "0.2.1" +version = "0.2.2" dependencies = [ "serde", "serde_json", From af1152d53d2cbe03e4c53e2cf3166911f067a133 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:08:26 +0300 Subject: [PATCH 23/42] chore(deps): add tinyjuice submodule Adds the tinyjuice library as a git submodule under vendor/tinyjuice, pinning it to commit f17da96. This makes the dependency available for use in the project. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .gitmodules | 4 ++++ vendor/tinyjuice | 1 + 2 files changed, 5 insertions(+) create mode 160000 vendor/tinyjuice diff --git a/.gitmodules b/.gitmodules index 155c98d010..a7c232daf1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -49,3 +49,7 @@ 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/vendor/tinyjuice b/vendor/tinyjuice new file mode 160000 index 0000000000..f17da9640f --- /dev/null +++ b/vendor/tinyjuice @@ -0,0 +1 @@ +Subproject commit f17da9640f2c04fdf98f89f0cd0541ee4aa02692 From 67d412f2db4ace5db78555c2777e19953574fbd8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:08:36 +0300 Subject: [PATCH 24/42] chore(deps): add tinyjuice-bus dependency for compression wire contract Add the tinyjuice-bus crate as a non-optional dependency to replace a hand-copied set of wire types that were previously shared by convention only. The compression middleware sits in the kernel's agent turn path, so the contract types must always be available without a feature gate. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.toml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 4c8cf8dd96..691220e78c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -513,6 +513,25 @@ tinydocs-bus = { path = "vendor/tinydocs/crates/tinydocs-bus", optional = true } # Optional: exclusive to the default-ON `voice` feature. 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 # environment and its domains on it in the one order that works. From 77c293c010ef8ae6cbee9b8567c4fe33bf1d208e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:08:49 +0300 Subject: [PATCH 25/42] refactor(tokenjuice): replace duplicated wire types with tinyjuice-bus re-exports The module previously maintained its own copies of wire types that were shared by convention with the TinyJuice module, creating a maintenance hazard where changes on one side could silently break the other. These types are now re-exported from the `tinyjuice-bus` crate, which serves as the single source of truth for the contract, while preserving the existing paths used by call sites throughout the crate. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/inference/tokenjuice/types.rs | 233 ++------------------ 1 file changed, 22 insertions(+), 211 deletions(-) diff --git a/src/openhuman/inference/tokenjuice/types.rs b/src/openhuman/inference/tokenjuice/types.rs index 89b143731f..72ca1542b9 100644 --- a/src/openhuman/inference/tokenjuice/types.rs +++ b/src/openhuman/inference/tokenjuice/types.rs @@ -1,214 +1,25 @@ -//! 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 { From 3477d9752a13f050745aeeaf6b6e656ca7c1bb7d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:08:59 +0300 Subject: [PATCH 26/42] refactor(tokenjuice): replace inline method names with shared constants Move the local `InstallRequest` struct into the `types` module and replace all hardcoded RPC method name strings with the corresponding constants from `tinyjuice_bus::names::methods`, reducing duplication and ensuring method names stay in sync across the codebase. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/inference/tokenjuice/mod.rs | 26 ++++++++--------------- 1 file changed, 9 insertions(+), 17 deletions(-) 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()) } From 793baaab463c53080cf9eede44810ae92899116e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:09:14 +0300 Subject: [PATCH 27/42] feat(tokenjuice_host): use shared constants for ML host name and path Replace the hardcoded D-Bus name and path constants with the shared definitions from the tinyjuice_bus crate, ensuring the module and the ML host always agree on the bus address. A mismatch would otherwise cause a silent fallback to a non-ML compressor, losing compression without any visible error. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/tokenjuice_host.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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; From ac4923ae46010a0c77292bff687b506ba163722f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:09:51 +0300 Subject: [PATCH 28/42] feat(voice): add tinyjuice-bus dependency and update VAD event assertion Add the tinyjuice-bus crate as a dependency and update the voice test assertion to match the new IndexedVadEvent structure, which wraps VAD events with an index field for tracking event ordering. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 8 ++++++++ src/openhuman/modules/voice_tests.rs | 8 +++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 98f03e0338..0fa99ccf1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4185,6 +4185,7 @@ dependencies = [ "tinyflows", "tinyhosts", "tinyhumans-sdk", + "tinyjuice-bus", "tinymcp", "tinymcp-bus", "tinymemory", @@ -6512,6 +6513,13 @@ dependencies = [ "url", ] +[[package]] +name = "tinyjuice-bus" +version = "0.2.2" +dependencies = [ + "serde", +] + [[package]] name = "tinymcp" version = "0.3.1" diff --git a/src/openhuman/modules/voice_tests.rs b/src/openhuman/modules/voice_tests.rs index f9a6335fc0..12e31f1056 100644 --- a/src/openhuman/modules/voice_tests.rs +++ b/src/openhuman/modules/voice_tests.rs @@ -288,7 +288,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")); From b9ff7ec730a0bd7766c5399a8566957bbbb7214d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:13:16 +0300 Subject: [PATCH 29/42] feat(limits): bump kernel floor to 287/269/2 for tinyjuice-bus crate The kernel floor limit is raised to account for the new `tinyjuice-bus` crate, which was extracted from `inference::tokenjuice` to eliminate duplicated wire type definitions. This crate carries no additional third-party dependencies and cannot be feature-gated because the compression middleware is always compiled, but it removes 259 lines of hand-copied types that had no consistency checking. Auto-committed-on: dragonfly Co-authored-by: Medulla --- scripts/kernel-floor.limits | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/scripts/kernel-floor.limits b/scripts/kernel-floor.limits index b21e9fe3c5..1ddc31de2b 100644 --- a/scripts/kernel-floor.limits +++ b/scripts/kernel-floor.limits @@ -13,6 +13,26 @@ # 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`). 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-22 combined raise from two independent extractions # landing on the same day, each additive on the 283/265/2 # base. (1) The MCP client moved out to `tinymcp` (+2 @@ -403,4 +423,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 From 0830d9dc44289fbbb60f27fe80b1733547174f0c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:14:21 +0300 Subject: [PATCH 30/42] docs(AGENTS.md): document module wire contract crates and update extraction notes Replace the old section on vendored host-agnostic crates with a detailed explanation of the new `*-bus` contract crate pattern, where each loadable module ships an ordinary crate carrying its call vocabulary as a git submodule. Clarify that contract types must never be redeclared, members must be called by constant rather than string, and host policy stays host-side. Update the extracted crate section to reflect that `tinydocs` has been replaced by `tinydocs-bus` and that `tinywallet` remains the only module without a separate bus crate. Auto-committed-on: dragonfly Co-authored-by: Medulla --- AGENTS.md | 110 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 81 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 79d0431a4c..b0e77625e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,38 +200,75 @@ 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`). -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. +| 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/**` | +| `tinymcp-bus` | `mcp` | `mcp/**` | + +After cloning: `git submodule update --init --recursive vendor/`. + +**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 `modules/{documents,voice}_tests.rs` +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`. + +### Extracted host-agnostic crates — `vendor/tinywallet` + +Separately from the contract crates above, `tinywallet` owns logic that used to +live in this repo and is not wire vocabulary. 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.** The crate is 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/` | | `tinywallet` | the BTC / EVM / Solana / Tron address formats: parsing, validation, encoding conversions | RPC endpoint resolution, transaction assembly and broadcast, key custody — `src/openhuman/web3/` | -Consequences worth knowing before touching either seam: +`tinywallet` has **no `-bus` crate yet**, so it is the one module whose +contract (`tinywallet::wire`) is reached through a feature of the root crate +rather than a separate one. The chain and signing core is still out of the +build — `bitcoin`, `ethers-*` and `coins-bip39` are absent because `key`, `tx` +and `client` are off — but the arrangement differs from every other module here. + +Consequences worth knowing before touching that 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. - **`tinywallet` 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` @@ -241,11 +278,26 @@ 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. -- **Each crate's gates ride OpenHuman's existing ones**: `tinydocs` is - exclusive to `documents`, `tinywallet` to `web3`. Both are default-ON and - already forwarded to the desktop shell. Note `tinydocs` is now taken with - `default-features = false` — the wire contract, not the writers, which run in - the TinyBus module instead (see the module host section). +- **Its gate rides OpenHuman's existing one**: `tinywallet` is exclusive to + `web3`, which is default-ON in the product set and already forwarded to the + desktop shell. + +Two consequences of the document seam survive the move to `tinydocs-bus` and +still apply: + +- **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. ### Backend API access — `src/api/` over `tinyhumans-sdk` From 5e7f9ee96623e5313f6d343b26f3085bacd16c66 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:14:39 +0300 Subject: [PATCH 31/42] docs(AGENTS.md): correct the `documents` feature description to reflect the actual dependency The table entry for the `documents` feature previously stated that `tinydocs` was consumed with `default-features = false`, but the dependency is actually on the `tinydocs-bus` wire contract crate alone, with no other crates from that repository. The updated description now accurately reflects the dependency structure. Auto-committed-on: dragonfly Co-authored-by: Medulla --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b0e77625e0..5f713b8f32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -663,7 +663,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` | From a0e85bc22100f7d230c7a40aa208de4377c0bd0c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:24:38 +0300 Subject: [PATCH 32/42] chore(registry): update tinyjuice module to version 0.2.4 Update the tinyjuice module record in the registry to version 0.2.4, including updated release URLs, archive filenames, and SHA-256 checksums for all supported platforms. The vendor submodule pointer is also advanced to the corresponding commit. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/modules/registry.rs | 48 +++++++++++++++---------------- vendor/tinyjuice | 2 +- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/openhuman/modules/registry.rs b/src/openhuman/modules/registry.rs index 06292dcce1..3c5e4c81a8 100644 --- a/src/openhuman/modules/registry.rs +++ b/src/openhuman/modules/registry.rs @@ -278,63 +278,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, diff --git a/vendor/tinyjuice b/vendor/tinyjuice index f17da9640f..0c7f828169 160000 --- a/vendor/tinyjuice +++ b/vendor/tinyjuice @@ -1 +1 @@ -Subproject commit f17da9640f2c04fdf98f89f0cd0541ee4aa02692 +Subproject commit 0c7f828169f626c252ca752ce7c239dfa4c28bb4 From 166995ff1729f34180d7524cb629944dab6666f4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:24:50 +0300 Subject: [PATCH 33/42] chore(deps): bump tinyjuice-bus from 0.2.2 to 0.2.4 Update the tinyjuice-bus dependency to version 0.2.4 in the lockfile to reflect the new version specified in the manifest. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 0fa99ccf1b..4eadc1cc04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6515,7 +6515,7 @@ dependencies = [ [[package]] name = "tinyjuice-bus" -version = "0.2.2" +version = "0.2.4" dependencies = [ "serde", ] From 0d1bb234baf9e6f972b12dd6201fb7dedbf9c115 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:52:46 +0300 Subject: [PATCH 34/42] chore: reformat long function call arguments and imports Reformatted several multi-line function call arguments and import statements to improve code readability by breaking them across multiple lines. The changes are purely stylistic with no behavioural impact. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/inference/tokenjuice/types.rs | 4 +++- src/openhuman/modules/voice.rs | 14 ++++++++++++-- src/openhuman/modules/voice_tests.rs | 9 +++++++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/openhuman/inference/tokenjuice/types.rs b/src/openhuman/inference/tokenjuice/types.rs index 72ca1542b9..8ccbcbabec 100644 --- a/src/openhuman/inference/tokenjuice/types.rs +++ b/src/openhuman/inference/tokenjuice/types.rs @@ -19,7 +19,9 @@ pub use tinyjuice_bus::types::{ AgentTokenjuiceCompression, CompressOptions, CompressedOutput, CompressorKind, ContentHint, ContentKind, }; -pub use tinyjuice_bus::wire::{CacheStats, CompactResponse, InstallRequest, RangeUnit, RetrieveRange}; +pub use tinyjuice_bus::wire::{ + CacheStats, CompactResponse, InstallRequest, RangeUnit, RetrieveRange, +}; #[cfg(test)] mod tests { diff --git a/src/openhuman/modules/voice.rs b/src/openhuman/modules/voice.rs index dd5d229a71..dcad2c0f08 100644 --- a/src/openhuman/modules/voice.rs +++ b/src/openhuman/modules/voice.rs @@ -182,7 +182,12 @@ pub async fn is_hallucinated( text: &str, mode: HallucinationMode, ) -> Result { - call(config, methods::IS_HALLUCINATED, (text, hallucination_mode_wire(mode))).await + call( + config, + methods::IS_HALLUCINATED, + (text, hallucination_mode_wire(mode)), + ) + .await } /// Downmix, resample to 16 kHz, optionally silence-gate, and frame as WAV. @@ -402,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, methods::ENCODE_WAV_PCM16, (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 12e31f1056..2403971f5c 100644 --- a/src/openhuman/modules/voice_tests.rs +++ b/src/openhuman/modules/voice_tests.rs @@ -65,7 +65,10 @@ 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!(hallucination_mode_wire(HallucinationMode::Dictation), "dictation"); + assert_eq!( + hallucination_mode_wire(HallucinationMode::Dictation), + "dictation" + ); assert_eq!( hallucination_mode_wire(HallucinationMode::Conversation), "conversation" @@ -305,7 +308,9 @@ async fn the_published_module_answers_through_this_client() { .expect("VadPush"); match events.as_slice() { [super::IndexedVadEvent { - event: super::VadEvent::SpeechEnd { voiced_ms, emit, .. }, + event: super::VadEvent::SpeechEnd { + voiced_ms, emit, .. + }, .. }] => { assert_eq!(*voiced_ms, 120, "voiced time carries across pushes"); From 8be067d70391a2778c4efd0efc56c43f7bf8071c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:53:21 +0300 Subject: [PATCH 35/42] chore(ci): bump expected dependency count to 269 The dependency simulation script now expects 269 names instead of 268 to account for the TinyJuice wire contract being moved into the `tinyjuice-bus` module, which cannot be feature-gated because `inference::tokenjuice` compiles in every build. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .github/workflows/ci-lite.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 From 8b5773db1c9ff72fdca6caa01b113771793c5938 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 12:58:56 +0300 Subject: [PATCH 36/42] chore(deps): update Cargo.lock with new bus crate dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cargo.lock file is updated to reflect the addition of three new bus crate dependencies — tinydocs-bus, tinyjuice-bus, and tinyvoice-bus — along with a version bump for tinyruntime-bus from 0.2.1 to 0.2.2, ensuring the lockfile stays in sync with the project's actual dependency graph. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src-tauri/Cargo.lock | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index b232d5e75a..88c8bcdac3 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4419,9 +4419,11 @@ dependencies = [ "tinychannels", "tinycortex", "tinycortex-api", + "tinydocs-bus", "tinyflows", "tinyhosts", "tinyhumans-sdk", + "tinyjuice-bus", "tinymcp", "tinymcp-bus", "tinymemory", @@ -4430,6 +4432,7 @@ dependencies = [ "tinymemory-tinycortex", "tinyplace", "tinyruntime-bus", + "tinyvoice-bus", "tinywallet", "tokio", "tokio-stream", @@ -7174,6 +7177,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" @@ -7222,6 +7233,13 @@ dependencies = [ "url", ] +[[package]] +name = "tinyjuice-bus" +version = "0.2.4" +dependencies = [ + "serde", +] + [[package]] name = "tinymcp" version = "0.3.1" @@ -7400,7 +7418,7 @@ dependencies = [ [[package]] name = "tinyruntime-bus" -version = "0.2.1" +version = "0.2.2" dependencies = [ "serde", "serde_json", @@ -7431,6 +7449,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.4.0" From efba51c75945db7d28ffb789d375816601679d4d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 14:06:32 +0300 Subject: [PATCH 37/42] fix(docs): correct feature default comments in Cargo.toml The comments for the `tinydocs-bus` and `tinyvoice-bus` dependencies incorrectly stated their features were "default-ON", which was true only before the contributor and product feature sets were split. The comments now reflect that both features are default-OFF for contributors but ON in the shipped product. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.toml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 691220e78c..e80908b6c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -496,7 +496,11 @@ unicode-width = { version = "0.2", optional = true } # 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. @@ -510,7 +514,8 @@ tinydocs-bus = { path = "vendor/tinydocs/crates/tinydocs-bus", optional = true } # # After cloning: `git submodule update --init vendor/tinyvoice`. # -# Optional: exclusive to the default-ON `voice` feature. +# 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. From c6bfa54687bb00f3da63ed6fd54beb98cdecaa5d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 15:32:10 +0300 Subject: [PATCH 38/42] chore(direct_engine_refs_tests): remove outdated entry for registry ops The test entry for `src/openhuman/tools/registry/ops.rs` was removed because the file no longer reaches engine storage below the contract, making the verdict obsolete. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/memory/direct_engine_refs_tests.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/openhuman/memory/direct_engine_refs_tests.rs b/src/openhuman/memory/direct_engine_refs_tests.rs index 1f76550966..2a8fe68b3f 100644 --- a/src/openhuman/memory/direct_engine_refs_tests.rs +++ b/src/openhuman/memory/direct_engine_refs_tests.rs @@ -598,11 +598,6 @@ const ALLOWED: &[(&str, Verdict, &str)] = &[ Verdict::NeedsWiderSeam, "task-local host policy living in the engine crate (source_scope::current_source_scope, source_scope::with_source_scope); belongs in tinymemory-api, not a bus method", ), - ( - "src/openhuman/tools/registry/ops.rs", - Verdict::NeedsWiderSeam, - "reaches engine storage below the contract (store::chunks::store); MemoryChunks is read-only (list_chunks/get_chunk/chunk_detail/storage_kinds/chunk_embeddings) with no write or transaction door", - ), ( "src/openhuman/web_chat/run_task.rs", Verdict::NeedsWiderSeam, From 69c57e0863348898063a10f09ff5bbd7a2e2a130 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 17:05:54 +0300 Subject: [PATCH 39/42] fix(tests): use host rpc_models in memory threads e2e test The test was importing request and record types from `tinymemory_core::rpc_models`, but the handlers in `openhuman_core` consume the host's own `rpc_models` types with the same names. This mismatch caused type errors at runtime. The change switches the imports to come from `openhuman_core::openhuman::memory::rpc_models` instead, keeping only the shared envelope and query types from `tinymemory_core`. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../memory_threads_raw_coverage_e2e.rs | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs index cf6c4be0d6..1e3b33025f 100644 --- a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs @@ -139,18 +139,20 @@ 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, - }, + rpc_models::{ApiEnvelope, ApiError, ApiMeta, PaginationMeta, QueryNamespaceRequest, RecallContextRequest, RecallMemoriesRequest}, traits::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts}, util::redact::{redact, redact_endpoint}, }; From 4bd78355ce853cea2e56d70441dd7d7a8b404d27 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 17:06:08 +0300 Subject: [PATCH 40/42] chore(tests): reorder imports and reformat assertion macros in memory coverage test Reorganize the import statements in the memory threads raw coverage end-to-end test to follow the project's convention of grouping related imports together and sorting them alphabetically. Also reformat several assertion macros to use block-style formatting for improved readability and consistency with the project's coding style. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../memory_threads_raw_coverage_e2e.rs | 903 ++++++++++-------- 1 file changed, 520 insertions(+), 383 deletions(-) diff --git a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs index 1e3b33025f..f65024cb7e 100644 --- a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs @@ -5,10 +5,10 @@ //! hermetic while still exercising production code paths that are awkward to //! reach through full JSON-RPC flows. +use axum::Router; use axum::http::{HeaderMap, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::get; -use axum::Router; use chrono::{TimeZone, Utc}; use serde_json::json; use serde_json::{Map, Value}; @@ -29,15 +29,10 @@ use openhuman_core::openhuman::memory::query::{ MemoryQueryTool, MemoryTreeDrillDownTool, MemoryTreeFetchLeavesTool, MemoryTreeIngestDocumentTool, MemoryTreeQuerySourceTool, MemoryTreeSearchEntitiesTool, }; -use tinymemory_core::queue::types::ReembedBackfillPayload; -use tinymemory_core::queue::{ - self as memory_queue, AppendBufferPayload, AppendTarget, ExtractChunkPayload, - FlushStalePayload, JobKind, JobStatus, NewJob, NodeRef, SealPayload, DEFAULT_LOCK_DURATION_MS, -}; use openhuman_core::openhuman::memory::sources::readers::reader_for; use openhuman_core::openhuman::memory::sources::registry; use openhuman_core::openhuman::memory::sources::rpc as memory_sources_rpc; -use openhuman_core::openhuman::memory::sources::status::{source_status, FreshnessLabel}; +use openhuman_core::openhuman::memory::sources::status::{FreshnessLabel, source_status}; use openhuman_core::openhuman::memory::sources::sync::sync_source; use openhuman_core::openhuman::memory::sources::types::{ ContentType, MemorySourceEntry, SourceContent, SourceItem, SourceKind, @@ -45,22 +40,11 @@ use openhuman_core::openhuman::memory::sources::types::{ use openhuman_core::openhuman::memory::sources::{ all_memory_sources_controller_schemas, all_memory_sources_registered_controllers, }; -use tinymemory_core::store::chunks::store::{upsert_chunks, with_connection}; -use tinymemory_core::store::chunks::types::{ - approx_token_count, chunk_id, Chunk, DataSource, Metadata, SourceKind as ChunkSourceKind, - SourceRef, -}; -use tinymemory_core::store::trees::types::{ - SummaryNode, Tree, TreeKind, TreeStatus as StoredTreeStatus, -}; -use tinymemory_core::store::{ - MemoryClient, NamespaceDocumentInput, UnifiedMemory, -}; use openhuman_core::openhuman::memory::sync::composio; use openhuman_core::openhuman::memory::sync::composio::providers::profile::{ - canonicalize, delete_connected_identity_facets, is_self_identity, is_self_identity_any_toolkit, - load_connected_identities, render_connected_identities_section, ConnectedIdentity, - IdentityKind, + ConnectedIdentity, IdentityKind, canonicalize, delete_connected_identity_facets, + is_self_identity, is_self_identity_any_toolkit, load_connected_identities, + render_connected_identities_section, }; use openhuman_core::openhuman::memory::sync::composio::providers::profile_md::{ block_end, block_start, merge_provider_into_profile_md, remove_provider_from_profile_md, @@ -70,26 +54,26 @@ use openhuman_core::openhuman::memory::sync::composio::providers::slack::{ post_process as slack_post_process, schemas as slack_memory_schemas, }; use openhuman_core::openhuman::memory::sync::composio::providers::sync_state::{ - extract_item_id, DailyBudget, SyncState, DEFAULT_DAILY_REQUEST_LIMIT, + DEFAULT_DAILY_REQUEST_LIMIT, DailyBudget, SyncState, extract_item_id, }; use openhuman_core::openhuman::memory::sync::composio::providers::user_scopes; use openhuman_core::openhuman::memory::sync::composio::providers::{ + ComposioProvider, CuratedTool, NormalizedTask, ProviderContext, ProviderUserProfile, + SyncOutcome as ComposioSyncOutcome, SyncReason, TaskFetchFilter, ToolScope, UserScopePref, agent_ready_toolkits, all_providers as all_composio_providers, capability_matrix, catalog_for_toolkit, classify_unknown, curated_scope_for, find_curated, get_provider, init_default_providers as init_default_composio_providers, is_action_visible_with_pref, - register_provider, toolkit_from_slug, toolkit_has_scope, ComposioProvider, CuratedTool, - NormalizedTask, ProviderContext, ProviderUserProfile, SyncOutcome as ComposioSyncOutcome, - SyncReason, TaskFetchFilter, ToolScope, UserScopePref, + register_provider, toolkit_from_slug, toolkit_has_scope, }; use openhuman_core::openhuman::memory::sync::sync_status::{ rpc as memory_sync_status_rpc, schemas as memory_sync_status_schemas, }; use openhuman_core::openhuman::memory::tool_memory::prompt::{ - render_tool_memory_rules, ToolMemoryRulesSection, TOOL_MEMORY_HEADING, + TOOL_MEMORY_HEADING, ToolMemoryRulesSection, render_tool_memory_rules, }; use openhuman_core::openhuman::memory::tool_memory::{ - tool_memory_namespace, tool_memory_store, ToolMemoryPriority, ToolMemoryRule, ToolMemorySource, - TOOL_MEMORY_PROMPT_CAP, + TOOL_MEMORY_PROMPT_CAP, ToolMemoryPriority, ToolMemoryRule, ToolMemorySource, + tool_memory_namespace, tool_memory_store, }; use openhuman_core::openhuman::memory::tools::tool_memory::{ MemoryToolsListTool, MemoryToolsPutTool, @@ -104,34 +88,47 @@ use openhuman_core::openhuman::memory::tree::score::extract::{ }; use openhuman_core::openhuman::memory::tree::score::resolver::CanonicalEntity; use openhuman_core::openhuman::memory::tree::score::signals::{ - combine, combine_cheap_only, compute as compute_score_signals, entity_density_score, - interaction, metadata_weight, source_weight, token_count, unique_words, ScoreSignals, - SignalWeights, + ScoreSignals, SignalWeights, combine, combine_cheap_only, compute as compute_score_signals, + entity_density_score, interaction, metadata_weight, source_weight, token_count, unique_words, }; use openhuman_core::openhuman::memory::tree::score::store as score_store; -use openhuman_core::openhuman::memory::tree::score::{resolver, ScoringConfig}; +use openhuman_core::openhuman::memory::tree::score::{ScoringConfig, resolver}; use openhuman_core::openhuman::memory::tree::summarise::{ - fallback_summary, SummaryContext, SummaryInput, + SummaryContext, SummaryInput, fallback_summary, }; use openhuman_core::openhuman::memory::tree::tree::bucket_seal::LeafRef; use openhuman_core::openhuman::memory::tree::tree_runtime::store as tree_runtime_store; use openhuman_core::openhuman::memory::tree::tree_runtime::{ - all_tree_summarizer_controller_schemas, all_tree_summarizer_registered_controllers, - derive_node_ids, derive_parent_id, estimate_tokens, level_from_node_id, node_id_to_path, - NodeLevel, TreeNode, + NodeLevel, TreeNode, all_tree_summarizer_controller_schemas, + all_tree_summarizer_registered_controllers, derive_node_ids, derive_parent_id, estimate_tokens, + level_from_node_id, node_id_to_path, }; use openhuman_core::openhuman::memory::tree::{retrieval, score::embed}; -use tinymemory_core::tree_policy::TreePolicy; -use tinymemory_core::tree_source; use openhuman_core::openhuman::memory::{ - all_memory_controller_schemas, all_memory_registered_controllers, + MemoryIngestionConfig, MemoryIngestionRequest, all_memory_controller_schemas, + all_memory_registered_controllers, preferences::{ - load_general_preferences, recall_related_preferences, recall_situational_preferences, - USER_PREF_GENERAL_NAMESPACE, USER_PREF_SITUATIONAL_NAMESPACE, + USER_PREF_GENERAL_NAMESPACE, USER_PREF_SITUATIONAL_NAMESPACE, load_general_preferences, + recall_related_preferences, recall_situational_preferences, }, read_rpc as memory_read_rpc, - MemoryIngestionConfig, MemoryIngestionRequest, }; +use tinymemory_core::queue::types::ReembedBackfillPayload; +use tinymemory_core::queue::{ + self as memory_queue, AppendBufferPayload, AppendTarget, DEFAULT_LOCK_DURATION_MS, + ExtractChunkPayload, FlushStalePayload, JobKind, JobStatus, NewJob, NodeRef, SealPayload, +}; +use tinymemory_core::store::chunks::store::{upsert_chunks, with_connection}; +use tinymemory_core::store::chunks::types::{ + Chunk, DataSource, Metadata, SourceKind as ChunkSourceKind, SourceRef, approx_token_count, + chunk_id, +}; +use tinymemory_core::store::trees::types::{ + SummaryNode, Tree, TreeKind, TreeStatus as StoredTreeStatus, +}; +use tinymemory_core::store::{MemoryClient, NamespaceDocumentInput, UnifiedMemory}; +use tinymemory_core::tree_policy::TreePolicy; +use tinymemory_core::tree_source; // `remember`, `rpc_models`, `traits` and `util` moved into the extracted engine // crate with the rest of the memory implementation; the host re-exports some of // their contents flat but not the modules themselves. @@ -150,13 +147,8 @@ use openhuman_core::openhuman::memory::rpc_models::{ UpdateConversationMessageRequest, UpdateConversationThreadLabelsRequest, UpdateConversationThreadTitleRequest, UpsertConversationThreadRequest, WriteMemoryFileRequest, }; -use tinymemory_core::{ - remember::RememberSourceKind, - rpc_models::{ApiEnvelope, ApiError, ApiMeta, PaginationMeta, QueryNamespaceRequest, RecallContextRequest, RecallMemoriesRequest}, - traits::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts}, - util::redact::{redact, redact_endpoint}, -}; use openhuman_core::openhuman::security::{AutonomyLevel, SecurityPolicy}; +use openhuman_core::openhuman::threads::ThreadsError; use openhuman_core::openhuman::threads::ops as thread_ops; use openhuman_core::openhuman::threads::title::{ build_title_prompt, collapse_whitespace, is_auto_generated_thread_title, @@ -167,22 +159,30 @@ use openhuman_core::openhuman::threads::turn_state::{ SubagentActivity, SubagentToolCall, ToolTimelineEntry, ToolTimelineStatus, TurnLifecycle, TurnPhase, TurnState, TurnStateMirror, TurnStateStore, }; -use openhuman_core::openhuman::threads::ThreadsError; use openhuman_core::openhuman::threads::{ all_threads_controller_schemas, all_threads_registered_controllers, }; use openhuman_core::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory}; use tinycortex::memory::ingest::canonicalize::chat::{ - canonicalise as canonicalise_chat, ChatBatch, ChatMessage, + ChatBatch, ChatMessage, canonicalise as canonicalise_chat, }; use tinycortex::memory::ingest::canonicalize::document::{ - canonicalise as canonicalise_document, DocumentInput, + DocumentInput, canonicalise as canonicalise_document, }; use tinycortex::memory::ingest::canonicalize::email::{ - canonicalise as canonicalise_email, EmailMessage, EmailThread, + EmailMessage, EmailThread, canonicalise as canonicalise_email, }; use tinycortex::memory::ingest::canonicalize::email_clean; use tinycortex::memory::sync::{SyncOutcome as PipelineSyncOutcome, SyncPipelineKind}; +use tinymemory_core::{ + remember::RememberSourceKind, + rpc_models::{ + ApiEnvelope, ApiError, ApiMeta, PaginationMeta, QueryNamespaceRequest, + RecallContextRequest, RecallMemoriesRequest, + }, + traits::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts}, + util::redact::{redact, redact_endpoint}, +}; struct EnvVarGuard { key: &'static str, @@ -512,16 +512,20 @@ Kitchen is north of Garden. assert!(result.relation_count >= 8); assert!(result.preference_count >= 1); assert!(result.decision_count >= 2); - assert!(result - .entities - .iter() - .any(|entity| entity.name == "ALICE MORGAN")); - assert!(result - .relations - .iter() - .any(|relation| relation.subject.contains("OPENHUMAN") - && relation.predicate == "USES" - && relation.object.contains("TEXT-EMBEDDING"))); + assert!( + result + .entities + .iter() + .any(|entity| entity.name == "ALICE MORGAN") + ); + assert!( + result + .relations + .iter() + .any(|relation| relation.subject.contains("OPENHUMAN") + && relation.predicate == "USES" + && relation.object.contains("TEXT-EMBEDDING")) + ); let rows = memory .graph_query_namespace("memory-raw-ingestion", Some("ALICE MORGAN"), Some("OWNS")) @@ -537,19 +541,23 @@ Kitchen is north of Garden. ) .await .expect("query context"); - assert!(context - .hits - .iter() - .flat_map(|hit| hit.supporting_relations.iter()) - .any(|relation| relation.predicate == "OWNS" || relation.predicate == "USES")); + assert!( + context + .hits + .iter() + .flat_map(|hit| hit.supporting_relations.iter()) + .any(|relation| relation.predicate == "OWNS" || relation.predicate == "USES") + ); let recall = memory .recall_namespace_memories("memory-raw-ingestion", 5) .await .expect("recall memories"); - assert!(recall - .iter() - .any(|hit| hit.document_id.as_deref() == Some("doc-memory-raw-ingestion"))); + assert!( + recall + .iter() + .any(|hit| hit.document_id.as_deref() == Some("doc-memory-raw-ingestion")) + ); let extract_again = memory .extract_graph( @@ -638,11 +646,13 @@ async fn memory_source_readers_validate_and_use_local_inputs_only() { let mut twitter = source(SourceKind::TwitterQuery, "src_tw"); twitter.query = Some("AI safety".into()); - assert!(reader_for(&SourceKind::TwitterQuery) - .list_items(&twitter, &config) - .await - .unwrap_err() - .contains("not yet configured")); + assert!( + reader_for(&SourceKind::TwitterQuery) + .list_items(&twitter, &config) + .await + .unwrap_err() + .contains("not yet configured") + ); let mut composio = source(SourceKind::Composio, "src_cmp"); composio.toolkit = Some("gmail".into()); @@ -656,12 +666,14 @@ async fn memory_source_readers_validate_and_use_local_inputs_only() { .title, "gmail connection" ); - assert!(composio_reader - .read_item(&composio, "conn-1", &config) - .await - .expect("composio read") - .body - .contains("provider sync pipeline")); + assert!( + composio_reader + .read_item(&composio, "conn-1", &config) + .await + .expect("composio read") + .body + .contains("provider sync pipeline") + ); for (kind, expected) in [ (SourceKind::Composio, "composio"), @@ -673,40 +685,52 @@ async fn memory_source_readers_validate_and_use_local_inputs_only() { ] { assert_eq!(kind.as_str(), expected); } - assert!(source(SourceKind::GithubRepo, "bad") - .validate() - .unwrap_err() - .contains("url")); - assert!(source(SourceKind::TwitterQuery, "bad") - .validate() - .unwrap_err() - .contains("query")); + assert!( + source(SourceKind::GithubRepo, "bad") + .validate() + .unwrap_err() + .contains("url") + ); + assert!( + source(SourceKind::TwitterQuery, "bad") + .validate() + .unwrap_err() + .contains("query") + ); let mut github = source(SourceKind::GithubRepo, "src_github"); github.url = Some("https://github.com/tinyhumansai/openhuman".into()); let github_reader = reader_for(&SourceKind::GithubRepo); assert_eq!(github_reader.kind(), SourceKind::GithubRepo); - assert!(github_reader - .read_item(&github, "unknown:123", &config) - .await - .unwrap_err() - .contains("invalid item id")); - assert!(github_reader - .read_item(&github, "issue:not-a-number", &config) - .await - .unwrap_err() - .contains("invalid issue number")); - assert!(github_reader - .read_item(&github, "pr:not-a-number", &config) - .await - .unwrap_err() - .contains("invalid PR number")); + assert!( + github_reader + .read_item(&github, "unknown:123", &config) + .await + .unwrap_err() + .contains("invalid item id") + ); + assert!( + github_reader + .read_item(&github, "issue:not-a-number", &config) + .await + .unwrap_err() + .contains("invalid issue number") + ); + assert!( + github_reader + .read_item(&github, "pr:not-a-number", &config) + .await + .unwrap_err() + .contains("invalid PR number") + ); github.url = Some("https://github.com/tinyhumansai/openhuman/tree/main".into()); - assert!(github_reader - .list_items(&github, &config) - .await - .unwrap_err() - .contains("expected https://github.com//")); + assert!( + github_reader + .list_items(&github, &config) + .await + .unwrap_err() + .contains("expected https://github.com//") + ); } #[tokio::test] @@ -802,19 +826,23 @@ async fn memory_thread_tree_and_sync_controller_schemas_execute_public_handlers( thread_schemas.len() ); for function in expected_thread_functions { - assert!(thread_schemas - .iter() - .any(|schema| schema.namespace == "threads" && schema.function == function)); + assert!( + thread_schemas + .iter() + .any(|schema| schema.namespace == "threads" && schema.function == function) + ); } let thread_upsert = thread_controllers .iter() .find(|controller| controller.schema.function == "upsert") .expect("threads upsert controller"); - assert!((thread_upsert.handler)(Map::new()) - .await - .unwrap_err() - .contains("invalid params")); + assert!( + (thread_upsert.handler)(Map::new()) + .await + .unwrap_err() + .contains("invalid params") + ); let task_board_put = thread_controllers .iter() @@ -862,10 +890,12 @@ async fn memory_thread_tree_and_sync_controller_schemas_execute_public_handlers( .iter() .find(|schema| schema.function == "ingest") .expect("ingest schema"); - assert!(ingest_schema - .inputs - .iter() - .any(|field| field.name == "metadata" && !field.required)); + assert!( + ingest_schema + .inputs + .iter() + .any(|field| field.name == "metadata" && !field.required) + ); let tree_status = tree_controllers .iter() @@ -885,10 +915,12 @@ async fn memory_thread_tree_and_sync_controller_schemas_execute_public_handlers( bad_ingest.insert("namespace".into(), json!("schema_handlers")); bad_ingest.insert("content".into(), json!("content")); bad_ingest.insert("timestamp".into(), json!(123)); - assert!((tree_ingest.handler)(bad_ingest) - .await - .unwrap_err() - .contains("expected string")); + assert!( + (tree_ingest.handler)(bad_ingest) + .await + .unwrap_err() + .contains("expected string") + ); let sync_schemas = memory_sync_status_schemas::all_controller_schemas(); let sync_controllers = memory_sync_status_schemas::all_registered_controllers(); @@ -930,11 +962,13 @@ async fn memory_thread_tree_and_sync_controller_schemas_execute_public_handlers( let status_json = (sync_controllers[0].handler)(Map::new()) .await .expect("sync status controller"); - assert!(status_json["statuses"] - .as_array() - .unwrap() - .iter() - .any(|row| row["provider"] == "slack")); + assert!( + status_json["statuses"] + .as_array() + .unwrap() + .iter() + .any(|row| row["provider"] == "slack") + ); let slack_schemas = slack_memory_schemas::all_slack_memory_controller_schemas(); let slack_controllers = slack_memory_schemas::all_slack_memory_registered_controllers(); @@ -947,10 +981,12 @@ async fn memory_thread_tree_and_sync_controller_schemas_execute_public_handlers( .expect("slack sync trigger controller"); let mut bad_trigger = Map::new(); bad_trigger.insert("connection_id".into(), json!(123)); - assert!((trigger.handler)(bad_trigger) - .await - .unwrap_err() - .contains("invalid params")); + assert!( + (trigger.handler)(bad_trigger) + .await + .unwrap_err() + .contains("invalid params") + ); } #[test] @@ -998,9 +1034,11 @@ fn memory_schema_registries_and_query_tool_metadata_cover_public_surfaces() { let schema = openhuman_core::openhuman::memory::schemas::schemas(function); assert_eq!(schema.namespace, "memory"); assert_eq!(schema.function, function); - assert!(memory_schemas - .iter() - .any(|candidate| candidate.function == function)); + assert!( + memory_schemas + .iter() + .any(|candidate| candidate.function == function) + ); } assert_eq!( openhuman_core::openhuman::memory::schemas::schemas("missing").function, @@ -1040,9 +1078,11 @@ fn memory_schema_registries_and_query_tool_metadata_cover_public_surfaces() { let schema = openhuman_core::openhuman::memory::schema::schemas(function); assert_eq!(schema.namespace, "memory_tree"); assert_eq!(schema.function, function); - assert!(legacy_tree_schemas - .iter() - .any(|candidate| candidate.function == function)); + assert!( + legacy_tree_schemas + .iter() + .any(|candidate| candidate.function == function) + ); } assert_eq!( openhuman_core::openhuman::memory::schema::schemas("missing").function, @@ -1054,11 +1094,13 @@ fn memory_schema_registries_and_query_tool_metadata_cover_public_surfaces() { assert_eq!(consolidated.name(), "memory_tree"); assert_eq!(consolidated.category(), ToolCategory::System); assert_eq!(consolidated.permission_level(), PermissionLevel::ReadOnly); - assert!(schema["properties"]["mode"]["enum"] - .as_array() - .unwrap() - .iter() - .any(|mode| mode == "walk")); + assert!( + schema["properties"]["mode"]["enum"] + .as_array() + .unwrap() + .iter() + .any(|mode| mode == "walk") + ); for tool in [ &MemoryTreeSearchEntitiesTool as &dyn Tool, @@ -1264,9 +1306,11 @@ fn memory_sync_composio_catalog_scope_and_state_helpers_cover_edge_cases() { ); assert!(toolkit_has_scope("gmail", ToolScope::Admin)); assert!(catalog_for_toolkit("google_calendar").is_some()); - assert!(agent_ready_toolkits() - .windows(2) - .all(|pair| pair[0] <= pair[1])); + assert!( + agent_ready_toolkits() + .windows(2) + .all(|pair| pair[0] <= pair[1]) + ); let matrix = capability_matrix(); let gmail = matrix.iter().find(|cap| cap.toolkit == "gmail").unwrap(); @@ -1325,9 +1369,11 @@ fn slack_memory_schemas_and_post_processors_normalize_composio_shapes() { let schemas = slack_memory_schemas::all_slack_memory_controller_schemas(); assert_eq!(schemas.len(), 2); assert_eq!(schemas[0].namespace, "slack_memory"); - assert!(schemas - .iter() - .any(|schema| schema.function == "sync_status" && schema.inputs.is_empty())); + assert!( + schemas + .iter() + .any(|schema| schema.function == "sync_status" && schema.inputs.is_empty()) + ); let mut history = json!({ "data": { @@ -1402,14 +1448,18 @@ fn memory_tree_scoring_signal_helpers_cover_boundaries_and_serialization() { let regex_entities = openhuman_core::openhuman::memory::tree::score::extract::regex::extract( "Alice emailed bob@example.com from https://example.test and mentioned #coverage.", ); - assert!(regex_entities - .entities - .iter() - .any(|entity| entity.kind == EntityKind::Email && entity.text == "bob@example.com")); + assert!( + regex_entities + .entities + .iter() + .any(|entity| entity.kind == EntityKind::Email && entity.text == "bob@example.com") + ); let canonical = resolver::canonicalise(®ex_entities); - assert!(canonical - .iter() - .any(|entity| entity.canonical_id == "email:bob@example.com")); + assert!( + canonical + .iter() + .any(|entity| entity.canonical_id == "email:bob@example.com") + ); assert_eq!( resolver::canonical_id_for(EntityKind::Url, "https://Example.test/path/"), "url:https://Example.test/path/" @@ -1561,9 +1611,11 @@ fn memory_tree_runtime_store_buffers_and_retrieval_wire_helpers() { .summary, "Workspace root summary" ); - assert!(tree_runtime_store::read_node(&config, namespace, "missing") - .unwrap() - .is_none()); + assert!( + tree_runtime_store::read_node(&config, namespace, "missing") + .unwrap() + .is_none() + ); assert_eq!( tree_runtime_store::read_children(&config, namespace, "root") .unwrap() @@ -1628,9 +1680,11 @@ fn memory_tree_runtime_store_buffers_and_retrieval_wire_helpers() { .expect("buffer write second"); let buffered = tree_runtime_store::buffer_read(&config, namespace).expect("buffer read"); assert_eq!(buffered.len(), 2); - assert!(buffered - .iter() - .any(|(_, body)| body == "first buffered body")); + assert!( + buffered + .iter() + .any(|(_, body)| body == "first buffered body") + ); tree_runtime_store::buffer_delete( &config, namespace, @@ -1645,9 +1699,11 @@ fn memory_tree_runtime_store_buffers_and_retrieval_wire_helpers() { assert!(second_buffer.exists()); let drained = tree_runtime_store::buffer_drain(&config, namespace).expect("buffer drain"); assert_eq!(drained.len(), 1); - assert!(tree_runtime_store::buffer_read(&config, namespace) - .unwrap() - .is_empty()); + assert!( + tree_runtime_store::buffer_read(&config, namespace) + .unwrap() + .is_empty() + ); assert_eq!(NodeLevel::Hour.max_tokens(), 1_000); assert_eq!(NodeLevel::Month.parent_level(), Some(NodeLevel::Year)); @@ -1884,15 +1940,19 @@ async fn memory_read_rpc_score_index_and_summary_helpers_cover_dashboard_paths() .expect("score breakdown"); assert!(breakdown.kept); assert!(!breakdown.llm_consulted); - assert!(!breakdown - .signals - .iter() - .any(|signal| signal.name == "llm_importance" && signal.weight == 2.0)); - assert!(memory_read_rpc::chunk_score_rpc(&config, "missing".into()) - .await - .expect("missing chunk score") - .value - .is_none()); + assert!( + !breakdown + .signals + .iter() + .any(|signal| signal.name == "llm_importance" && signal.weight == 2.0) + ); + assert!( + memory_read_rpc::chunk_score_rpc(&config, "missing".into()) + .await + .expect("missing chunk score") + .value + .is_none() + ); let missing_delete = memory_read_rpc::delete_chunk_rpc(&config, "missing".into()) .await @@ -2106,7 +2166,8 @@ async fn memory_preferences_remember_redaction_and_pipeline_traits_cover_public_ // are host policy over a driver, not engine calls. `guarded_in_memory` // gives a real guard over a real store, so this still exercises the // decorator production uses rather than reaching past it. - let (_provider, memory) = openhuman_core::openhuman::memory::guard::in_memory::guarded_in_memory(); + let (_provider, memory) = + openhuman_core::openhuman::memory::guard::in_memory::guarded_in_memory(); memory .store( @@ -2145,12 +2206,16 @@ async fn memory_preferences_remember_redaction_and_pipeline_traits_cover_public_ let general = load_general_preferences(&memory, 10).await; assert_eq!(general, vec!["Prefer concise responses."]); assert!(load_general_preferences(&memory, 0).await.is_empty()); - assert!(recall_situational_preferences(&memory, " ") - .await - .is_empty()); - assert!(recall_related_preferences(&memory, " ", "tone", 3) - .await - .is_empty()); + assert!( + recall_situational_preferences(&memory, " ") + .await + .is_empty() + ); + assert!( + recall_related_preferences(&memory, " ", "tone", 3) + .await + .is_empty() + ); assert!( recall_related_preferences(&memory, "Prefer concise responses.", "tone", 0) .await @@ -2199,11 +2264,13 @@ async fn memory_tools_and_user_scope_prefs_cover_public_execution_paths() { let store_tool = MemoryStoreTool::new(security.clone()); assert_eq!(store_tool.name(), "memory_store"); - assert!(store_tool.parameters_schema()["required"] - .as_array() - .unwrap() - .iter() - .any(|field| field == "content")); + assert!( + store_tool.parameters_schema()["required"] + .as_array() + .unwrap() + .iter() + .any(|field| field == "content") + ); let stored = store_tool .execute(json!({ "namespace": "coverage-tools", @@ -2261,12 +2328,14 @@ async fn memory_tools_and_user_scope_prefs_cover_public_execution_paths() { .expect("recall tool"); assert!(!recalled.is_error); assert!(recalled.output().contains("rust")); - assert!(recall_tool - .execute(json!({ "namespace": "coverage-tools", "query": " " })) - .await - .unwrap_err() - .to_string() - .contains("query cannot be empty")); + assert!( + recall_tool + .execute(json!({ "namespace": "coverage-tools", "query": " " })) + .await + .unwrap_err() + .to_string() + .contains("query cannot be empty") + ); let forget_tool = MemoryForgetTool::new(security); assert_eq!(forget_tool.name(), "memory_forget"); @@ -2312,10 +2381,12 @@ async fn memory_tools_and_user_scope_prefs_cover_public_execution_paths() { user_scopes::load(&scoped_client, "gmail").await, UserScopePref::default() ); - assert!(user_scopes::save(&scoped_client, " ", pref) - .await - .unwrap_err() - .contains("toolkit must not be empty")); + assert!( + user_scopes::save(&scoped_client, " ", pref) + .await + .unwrap_err() + .contains("toolkit must not be empty") + ); assert_eq!( user_scopes::load_or_default("not-ready-toolkit").await, UserScopePref::default() @@ -2373,12 +2444,16 @@ async fn memory_queue_and_tool_memory_public_stores_cover_persistence_edges() { }, }; assert_eq!(extract.dedupe_key(), "extract:chunk-tool-memory"); - assert!(source_append - .dedupe_key() - .contains("append:source:slack:#raw:leaf:chunk-tool-memory")); - assert!(topic_append - .dedupe_key() - .contains("append:topic:topic:raw:summary:summary-tool-memory")); + assert!( + source_append + .dedupe_key() + .contains("append:source:slack:#raw:leaf:chunk-tool-memory") + ); + assert!( + topic_append + .dedupe_key() + .contains("append:topic:topic:raw:summary:summary-tool-memory") + ); assert_eq!( SealPayload { tree_id: "tree-1".into(), @@ -2407,9 +2482,11 @@ async fn memory_queue_and_tool_memory_public_stores_cover_persistence_edges() { let first_id = memory_queue::enqueue(&config, &first_job) .expect("enqueue") .expect("inserted"); - assert!(memory_queue::enqueue(&config, &first_job) - .expect("dedupe enqueue") - .is_none()); + assert!( + memory_queue::enqueue(&config, &first_job) + .expect("dedupe enqueue") + .is_none() + ); assert_eq!(memory_queue::count_total(&config).unwrap(), 1); assert_eq!( memory_queue::count_by_status(&config, JobStatus::Ready).unwrap(), @@ -2475,28 +2552,32 @@ async fn memory_queue_and_tool_memory_public_stores_cover_persistence_edges() { assert!(ToolMemoryPriority::High.is_eager()); assert!(!ToolMemoryPriority::Normal.is_eager()); assert_eq!(ToolMemorySource::default(), ToolMemorySource::Programmatic); - assert!(store - .record( - " ", - "blank tool rejected", - ToolMemoryPriority::High, - ToolMemorySource::UserExplicit, - Vec::new(), - ) - .await - .unwrap_err() - .contains("tool_name")); - assert!(store - .record( - "shell", - " ", - ToolMemoryPriority::High, - ToolMemorySource::UserExplicit, - Vec::new(), - ) - .await - .unwrap_err() - .contains("rule body")); + assert!( + store + .record( + " ", + "blank tool rejected", + ToolMemoryPriority::High, + ToolMemorySource::UserExplicit, + Vec::new(), + ) + .await + .unwrap_err() + .contains("tool_name") + ); + assert!( + store + .record( + "shell", + " ", + ToolMemoryPriority::High, + ToolMemorySource::UserExplicit, + Vec::new(), + ) + .await + .unwrap_err() + .contains("rule body") + ); let critical = store .record( @@ -2558,9 +2639,11 @@ async fn memory_queue_and_tool_memory_public_stores_cover_persistence_edges() { .rules_for_prompt(&[]) .await .expect("prompt rules from namespaces"); - assert!(prompt_rules["shell"] - .iter() - .all(|rule| rule.priority.is_eager())); + assert!( + prompt_rules["shell"] + .iter() + .all(|rule| rule.priority.is_eager()) + ); assert_eq!(TOOL_MEMORY_PROMPT_CAP, 30); let render_rules: Vec = [normal.clone(), updated.clone(), high.clone()] .into_iter() @@ -2575,43 +2658,55 @@ async fn memory_queue_and_tool_memory_public_stores_cover_persistence_edges() { assert!(rendered.contains("### `shell`")); assert!(ToolMemoryRulesSection::empty().is_empty()); assert!(!ToolMemoryRulesSection::new(vec![updated.clone()]).is_empty()); - assert!(store - .delete_rule("shell", &normal.id) - .await - .expect("delete normal")); - assert!(!store - .delete_rule("shell", &normal.id) - .await - .expect("delete missing")); - assert!(store - .get_rule("shell", &normal.id) - .await - .expect("missing normal") - .is_none()); + assert!( + store + .delete_rule("shell", &normal.id) + .await + .expect("delete normal") + ); + assert!( + !store + .delete_rule("shell", &normal.id) + .await + .expect("delete missing") + ); + assert!( + store + .get_rule("shell", &normal.id) + .await + .expect("missing normal") + .is_none() + ); let put_tool = MemoryToolsPutTool; assert_eq!(put_tool.name(), "memory_tools_put"); assert_eq!(put_tool.category(), ToolCategory::System); - assert!(put_tool.parameters_schema()["required"] - .as_array() - .unwrap() - .iter() - .any(|field| field == "rule")); - assert!(put_tool - .execute(json!({ "tool_name": "shell" })) - .await - .unwrap_err() - .to_string() - .contains("invalid arguments for memory_tools_put")); + assert!( + put_tool.parameters_schema()["required"] + .as_array() + .unwrap() + .iter() + .any(|field| field == "rule") + ); + assert!( + put_tool + .execute(json!({ "tool_name": "shell" })) + .await + .unwrap_err() + .to_string() + .contains("invalid arguments for memory_tools_put") + ); let list_tool = MemoryToolsListTool; assert_eq!(list_tool.name(), "memory_tools_list"); assert_eq!(list_tool.permission_level(), PermissionLevel::ReadOnly); - assert!(list_tool - .execute(json!({})) - .await - .unwrap_err() - .to_string() - .contains("invalid arguments for memory_tools_list")); + assert!( + list_tool + .execute(json!({})) + .await + .unwrap_err() + .to_string() + .contains("invalid arguments for memory_tools_list") + ); assert_eq!( ToolMemoryRule::storage_key(&updated.id), format!("rule/{}", updated.id) @@ -2632,10 +2727,12 @@ async fn memory_source_sync_entrypoint_rejects_disabled_and_ingests_folder_items let mut disabled = source(SourceKind::Folder, "src_disabled"); disabled.path = Some(tmp.path().to_string_lossy().to_string()); disabled.enabled = false; - assert!(sync_source(disabled, Arc::new(config.clone())) - .await - .unwrap_err() - .contains("disabled")); + assert!( + sync_source(disabled, Arc::new(config.clone())) + .await + .unwrap_err() + .contains("disabled") + ); let mut folder = source(SourceKind::Folder, "src_sync"); folder.path = Some(tmp.path().to_string_lossy().to_string()); @@ -2940,9 +3037,11 @@ fn gmail_post_processor_and_provider_registry_cover_public_edges() { init_default_composio_providers(); assert!(get_provider(" gmail ").is_some()); assert!(get_provider("unknown_provider_slug").is_none()); - assert!(all_composio_providers() - .iter() - .any(|provider| provider.toolkit_slug() == "slack")); + assert!( + all_composio_providers() + .iter() + .any(|provider| provider.toolkit_slug() == "slack") + ); register_provider(Arc::new(RawCoverageProvider { fail_profile: false, })); @@ -3017,8 +3116,7 @@ async fn memory_sync_provider_trait_defaults_and_connection_hook_are_determinist // unready client and see 0 instead of 1. Bind the global to this test's // workspace up front so the assertion is independent of execution order. ensure_memory_seams(); - tinymemory_core::global::init(tmp.path().to_path_buf()) - .expect("init global memory client"); + tinymemory_core::global::init(tmp.path().to_path_buf()).expect("init global memory client"); let ctx = ProviderContext { config: Arc::new(config_in(&tmp)), toolkit: "raw_coverage".into(), @@ -3030,11 +3128,13 @@ async fn memory_sync_provider_trait_defaults_and_connection_hook_are_determinist let provider = RawCoverageProvider { fail_profile: true }; assert_eq!(provider.sync_interval_secs(), Some(15 * 60)); assert!(provider.curated_tools().is_none()); - assert!(provider - .fetch_tasks(&ctx, &TaskFetchFilter::default()) - .await - .unwrap_err() - .contains("provider has no task-fetch surface")); + assert!( + provider + .fetch_tasks(&ctx, &TaskFetchFilter::default()) + .await + .unwrap_err() + .contains("provider has no task-fetch surface") + ); let mut action_data = json!({ "ok": true }); provider.post_process_action_result("RAW_ACTION", None, &mut action_data); @@ -3088,10 +3188,12 @@ fn turn_state_mirror_persists_progress_edges_from_public_events() { let tmp = TempDir::new().expect("tempdir"); let store = TurnStateStore::new(tmp.path().to_path_buf()); let mut mirror = TurnStateMirror::new(store.clone(), "thread/mirror", "request-mirror"); - assert!(store - .get("thread/mirror") - .expect("initial snapshot") - .is_some()); + assert!( + store + .get("thread/mirror") + .expect("initial snapshot") + .is_some() + ); assert!(mirror.observe(&AgentProgress::TurnStarted)); assert!(mirror.observe(&AgentProgress::IterationStarted { @@ -3228,10 +3330,12 @@ fn turn_state_mirror_persists_progress_edges_from_public_events() { assert_eq!(snapshot.streaming_text, "visible"); assert_eq!(snapshot.thinking, "thinking "); assert_eq!(snapshot.task_board, Some(board)); - assert!(snapshot - .tool_timeline - .iter() - .any(|entry| entry.id == "call-1" && entry.status == ToolTimelineStatus::Error)); + assert!( + snapshot + .tool_timeline + .iter() + .any(|entry| entry.id == "call-1" && entry.status == ToolTimelineStatus::Error) + ); assert!(snapshot.tool_timeline.iter().any(|entry| { entry.id == "subagent:task-1" && entry.status == ToolTimelineStatus::Error @@ -3375,10 +3479,12 @@ fn memory_source_types_and_freshness_cover_validation_matrix() { let mut composio_source = source(SourceKind::Composio, "cmp"); assert!(composio_source.validate().unwrap_err().contains("toolkit")); composio_source.toolkit = Some("gmail".into()); - assert!(composio_source - .validate() - .unwrap_err() - .contains("connection_id")); + assert!( + composio_source + .validate() + .unwrap_err() + .contains("connection_id") + ); composio_source.connection_id = Some("conn-1".into()); assert!(composio_source.validate().is_ok()); @@ -3470,9 +3576,11 @@ fn turn_state_store_persists_lists_marks_and_clears_snapshots() { .as_deref(), Some("research") ); - assert!(turn_state::store::get(workspace.clone(), "missing") - .unwrap() - .is_none()); + assert!( + turn_state::store::get(workspace.clone(), "missing") + .unwrap() + .is_none() + ); let mut listed = turn_state::store::list(workspace.clone()).expect("list states"); listed.sort_by(|a, b| a.thread_id.cmp(&b.thread_id)); @@ -3651,10 +3759,12 @@ async fn threads_rpc_ops_cover_crud_title_fallback_and_turn_state_cleanup() { .data .expect("threads"); assert!(all_threads.count >= 2); - assert!(all_threads - .threads - .iter() - .any(|thread| thread.title == "Manual coverage title")); + assert!( + all_threads + .threads + .iter() + .any(|thread| thread.title == "Manual coverage title") + ); let mut turn = TurnState::started("thread/raw-crud", "request-raw", 3, "2026-05-29T12:02:00Z"); turn.lifecycle = TurnLifecycle::Streaming; @@ -3700,9 +3810,11 @@ async fn threads_rpc_ops_cover_crud_title_fallback_and_turn_state_cleanup() { .data .expect("delete response"); assert!(deleted.deleted); - assert!(turn_state::store::get(workspace_dir, "thread/raw-crud") - .unwrap() - .is_none()); + assert!( + turn_state::store::get(workspace_dir, "thread/raw-crud") + .unwrap() + .is_none() + ); let purged = thread_ops::threads_purge(EmptyRequest {}) .await @@ -3811,10 +3923,12 @@ async fn memory_sources_registry_rpc_and_schema_handlers_cover_crud_edges() { .expect("add controller"); let mut bad_params = Map::new(); bad_params.insert("kind".into(), Value::String("folder".into())); - assert!((add_controller.handler)(bad_params) - .await - .unwrap_err() - .contains("missing field `label`")); + assert!( + (add_controller.handler)(bad_params) + .await + .unwrap_err() + .contains("missing field `label`") + ); let invalid_folder = memory_sources_rpc::add_rpc(memory_sources_rpc::AddRequest { kind: SourceKind::Folder, @@ -3869,30 +3983,32 @@ async fn memory_sources_registry_rpc_and_schema_handlers_cover_crud_edges() { .value .source; assert_eq!(added.kind, SourceKind::Folder); - assert!(memory_sources_rpc::add_rpc(memory_sources_rpc::AddRequest { - kind: SourceKind::Folder, - label: "Duplicate".into(), - enabled: true, - toolkit: None, - connection_id: None, - path: Some(tmp.path().to_string_lossy().to_string()), - glob: None, - url: None, - branch: None, - paths: Vec::new(), - query: None, - since_days: None, - max_items: None, - max_commits: None, - max_issues: None, - max_prs: None, - selector: None, - max_tokens_per_sync: None, - max_cost_per_sync_usd: None, - sync_depth_days: None, - }) - .await - .is_ok()); + assert!( + memory_sources_rpc::add_rpc(memory_sources_rpc::AddRequest { + kind: SourceKind::Folder, + label: "Duplicate".into(), + enabled: true, + toolkit: None, + connection_id: None, + path: Some(tmp.path().to_string_lossy().to_string()), + glob: None, + url: None, + branch: None, + paths: Vec::new(), + query: None, + since_days: None, + max_items: None, + max_commits: None, + max_issues: None, + max_prs: None, + selector: None, + max_tokens_per_sync: None, + max_cost_per_sync_usd: None, + sync_depth_days: None, + }) + .await + .is_ok() + ); let enabled_folders = registry::list_enabled_by_kind(SourceKind::Folder) .await @@ -3910,14 +4026,16 @@ async fn memory_sources_registry_rpc_and_schema_handlers_cover_crud_edges() { .label, "Folder source" ); - assert!(memory_sources_rpc::get_rpc(memory_sources_rpc::GetRequest { - id: "missing".into(), - }) - .await - .expect("get missing") - .value - .source - .is_none()); + assert!( + memory_sources_rpc::get_rpc(memory_sources_rpc::GetRequest { + id: "missing".into(), + }) + .await + .expect("get missing") + .value + .source + .is_none() + ); let list_items = memory_sources_rpc::list_items_rpc(memory_sources_rpc::ListItemsRequest { source_id: added.id.clone(), @@ -4162,11 +4280,13 @@ async fn memory_ops_public_handlers_cover_document_file_kv_graph_and_envelopes_b .await .expect("doc list") .value; - assert!(direct_docs["documents"] - .as_array() - .unwrap() - .iter() - .any(|doc| doc["documentId"] == "doc-ops-raw")); + assert!( + direct_docs["documents"] + .as_array() + .unwrap() + .iter() + .any(|doc| doc["documentId"] == "doc-ops-raw") + ); let envelope_docs = openhuman_core::openhuman::memory::ops::memory_list_documents(ListDocumentsRequest { @@ -4328,11 +4448,13 @@ async fn memory_ops_public_handlers_cover_document_file_kv_graph_and_envelopes_b .await .expect("tool rules json") .value; - assert!(tool_rules_json - .as_array() - .unwrap() - .iter() - .any(|rule| rule["id"] == "ops-rule-1" && rule["priority"] == "high")); + assert!( + tool_rules_json + .as_array() + .unwrap() + .iter() + .any(|rule| rule["id"] == "ops-rule-1" && rule["priority"] == "high") + ); assert!( openhuman_core::openhuman::memory::ops::tool_rule_delete( openhuman_core::openhuman::memory::ops::ToolRuleRefParams { @@ -4344,16 +4466,18 @@ async fn memory_ops_public_handlers_cover_document_file_kv_graph_and_envelopes_b .expect("tool rule delete") .value ); - assert!(openhuman_core::openhuman::memory::ops::tool_rule_get( - openhuman_core::openhuman::memory::ops::ToolRuleRefParams { - tool_name: "shell".into(), - id: "ops-rule-1".into(), - }, - ) - .await - .expect("tool rule missing") - .value - .is_none()); + assert!( + openhuman_core::openhuman::memory::ops::tool_rule_get( + openhuman_core::openhuman::memory::ops::ToolRuleRefParams { + tool_name: "shell".into(), + id: "ops-rule-1".into(), + }, + ) + .await + .expect("tool rule missing") + .value + .is_none() + ); let delete_missing = openhuman_core::openhuman::memory::ops::memory_delete_document(DeleteDocumentRequest { @@ -4405,12 +4529,14 @@ async fn memory_tree_retrieval_rpc_and_schema_wrappers_cover_empty_and_invalid_p openhuman_core::openhuman::memory::tree::retrieval::schemas::schemas("missing").function, "unknown" ); - assert!(schemas - .iter() - .find(|schema| schema.function == "fetch_leaves") - .unwrap() - .description - .contains("Batch-fetch")); + assert!( + schemas + .iter() + .find(|schema| schema.function == "fetch_leaves") + .unwrap() + .description + .contains("Batch-fetch") + ); let source = openhuman_core::openhuman::memory::tree::retrieval::rpc::query_source_rpc( &config, @@ -4500,10 +4626,12 @@ async fn memory_tree_retrieval_rpc_and_schema_wrappers_cover_empty_and_invalid_p .expect("fetch controller"); let mut bad_params = Map::new(); bad_params.insert("chunk_ids".into(), json!("not-an-array")); - assert!((fetch_controller.handler)(bad_params) - .await - .unwrap_err() - .contains("invalid params")); + assert!( + (fetch_controller.handler)(bad_params) + .await + .unwrap_err() + .contains("invalid params") + ); } #[tokio::test] @@ -4685,14 +4813,18 @@ async fn memory_sources_types_registry_and_sync_state_cover_public_persistence_e assert_eq!(invalid.validate().unwrap_err(), "label is required"); invalid.label = "Missing path".into(); assert!(invalid.validate().unwrap_err().contains("path is required")); - assert!(source(SourceKind::RssFeed, "rss_missing") - .validate() - .unwrap_err() - .contains("url is required")); - assert!(source(SourceKind::WebPage, "web_missing") - .validate() - .unwrap_err() - .contains("url is required")); + assert!( + source(SourceKind::RssFeed, "rss_missing") + .validate() + .unwrap_err() + .contains("url is required") + ); + assert!( + source(SourceKind::WebPage, "web_missing") + .validate() + .unwrap_err() + .contains("url is required") + ); let mut entry = source(SourceKind::GithubRepo, "src_repo"); entry.url = Some("https://github.com/tinyhumansai/openhuman".into()); @@ -4700,10 +4832,12 @@ async fn memory_sources_types_registry_and_sync_state_cover_public_persistence_e .await .expect("add repo source"); assert_eq!(added.kind.as_str(), "github_repo"); - assert!(registry::add_source(entry) - .await - .unwrap_err() - .contains("already exists")); + assert!( + registry::add_source(entry) + .await + .unwrap_err() + .contains("already exists") + ); let patch: registry::MemorySourcePatch = serde_json::from_value(json!({ "label": "Updated repo", @@ -4741,8 +4875,7 @@ async fn memory_sources_types_registry_and_sync_state_cover_public_persistence_e MemoryClient::from_workspace_dir(tmp.path().join("memory-sync-state")) .expect("memory client"), ); - let adapter = - tinymemory_core::tinycortex::HostSyncAdapter::new(memory.clone()); + let adapter = tinymemory_core::tinycortex::HostSyncAdapter::new(memory.clone()); let fresh = SyncState::load(&adapter, "gmail", "conn-raw") .await .expect("fresh state"); @@ -4864,12 +4997,16 @@ fn welcome_migration_public_entrypoint_covers_empty_marker_and_transcript_paths( assert_eq!(result.transcripts_updated, 1); assert_eq!(result.transcript_files_renamed, 1); assert_eq!(result.markdown_files_renamed, 1); - assert!(workspace - .join("session_raw/1715000000_orchestrator_thread-abc.jsonl") - .exists()); - assert!(workspace - .join("sessions/2026_05_01/1715000000_orchestrator_thread-abc.md") - .exists()); + assert!( + workspace + .join("session_raw/1715000000_orchestrator_thread-abc.jsonl") + .exists() + ); + assert!( + workspace + .join("sessions/2026_05_01/1715000000_orchestrator_thread-abc.md") + .exists() + ); let second = openhuman_core::openhuman::threads::migrate_welcome_agent_artifacts(workspace) .expect("second migration"); From c3da246b4f25aa299c7abe8031f4439610c1ac85 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 17:07:31 +0300 Subject: [PATCH 41/42] chore(tests): reformat assertion macros in memory_threads_raw_coverage_e2e Reformat the `assert!` macro invocations throughout the test file to use a consistent style where the macro call wraps the entire expression on a single line, rather than splitting the assertion across multiple lines with the parentheses on separate lines. This change is purely cosmetic and does not alter any test logic or behaviour. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../memory_threads_raw_coverage_e2e.rs | 903 ++++++++---------- 1 file changed, 383 insertions(+), 520 deletions(-) diff --git a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs index f65024cb7e..1e3b33025f 100644 --- a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs @@ -5,10 +5,10 @@ //! hermetic while still exercising production code paths that are awkward to //! reach through full JSON-RPC flows. -use axum::Router; use axum::http::{HeaderMap, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::get; +use axum::Router; use chrono::{TimeZone, Utc}; use serde_json::json; use serde_json::{Map, Value}; @@ -29,10 +29,15 @@ use openhuman_core::openhuman::memory::query::{ MemoryQueryTool, MemoryTreeDrillDownTool, MemoryTreeFetchLeavesTool, MemoryTreeIngestDocumentTool, MemoryTreeQuerySourceTool, MemoryTreeSearchEntitiesTool, }; +use tinymemory_core::queue::types::ReembedBackfillPayload; +use tinymemory_core::queue::{ + self as memory_queue, AppendBufferPayload, AppendTarget, ExtractChunkPayload, + FlushStalePayload, JobKind, JobStatus, NewJob, NodeRef, SealPayload, DEFAULT_LOCK_DURATION_MS, +}; use openhuman_core::openhuman::memory::sources::readers::reader_for; use openhuman_core::openhuman::memory::sources::registry; use openhuman_core::openhuman::memory::sources::rpc as memory_sources_rpc; -use openhuman_core::openhuman::memory::sources::status::{FreshnessLabel, source_status}; +use openhuman_core::openhuman::memory::sources::status::{source_status, FreshnessLabel}; use openhuman_core::openhuman::memory::sources::sync::sync_source; use openhuman_core::openhuman::memory::sources::types::{ ContentType, MemorySourceEntry, SourceContent, SourceItem, SourceKind, @@ -40,11 +45,22 @@ use openhuman_core::openhuman::memory::sources::types::{ use openhuman_core::openhuman::memory::sources::{ all_memory_sources_controller_schemas, all_memory_sources_registered_controllers, }; +use tinymemory_core::store::chunks::store::{upsert_chunks, with_connection}; +use tinymemory_core::store::chunks::types::{ + approx_token_count, chunk_id, Chunk, DataSource, Metadata, SourceKind as ChunkSourceKind, + SourceRef, +}; +use tinymemory_core::store::trees::types::{ + SummaryNode, Tree, TreeKind, TreeStatus as StoredTreeStatus, +}; +use tinymemory_core::store::{ + MemoryClient, NamespaceDocumentInput, UnifiedMemory, +}; use openhuman_core::openhuman::memory::sync::composio; use openhuman_core::openhuman::memory::sync::composio::providers::profile::{ - ConnectedIdentity, IdentityKind, canonicalize, delete_connected_identity_facets, - is_self_identity, is_self_identity_any_toolkit, load_connected_identities, - render_connected_identities_section, + canonicalize, delete_connected_identity_facets, is_self_identity, is_self_identity_any_toolkit, + load_connected_identities, render_connected_identities_section, ConnectedIdentity, + IdentityKind, }; use openhuman_core::openhuman::memory::sync::composio::providers::profile_md::{ block_end, block_start, merge_provider_into_profile_md, remove_provider_from_profile_md, @@ -54,26 +70,26 @@ use openhuman_core::openhuman::memory::sync::composio::providers::slack::{ post_process as slack_post_process, schemas as slack_memory_schemas, }; use openhuman_core::openhuman::memory::sync::composio::providers::sync_state::{ - DEFAULT_DAILY_REQUEST_LIMIT, DailyBudget, SyncState, extract_item_id, + extract_item_id, DailyBudget, SyncState, DEFAULT_DAILY_REQUEST_LIMIT, }; use openhuman_core::openhuman::memory::sync::composio::providers::user_scopes; use openhuman_core::openhuman::memory::sync::composio::providers::{ - ComposioProvider, CuratedTool, NormalizedTask, ProviderContext, ProviderUserProfile, - SyncOutcome as ComposioSyncOutcome, SyncReason, TaskFetchFilter, ToolScope, UserScopePref, agent_ready_toolkits, all_providers as all_composio_providers, capability_matrix, catalog_for_toolkit, classify_unknown, curated_scope_for, find_curated, get_provider, init_default_providers as init_default_composio_providers, is_action_visible_with_pref, - register_provider, toolkit_from_slug, toolkit_has_scope, + register_provider, toolkit_from_slug, toolkit_has_scope, ComposioProvider, CuratedTool, + NormalizedTask, ProviderContext, ProviderUserProfile, SyncOutcome as ComposioSyncOutcome, + SyncReason, TaskFetchFilter, ToolScope, UserScopePref, }; use openhuman_core::openhuman::memory::sync::sync_status::{ rpc as memory_sync_status_rpc, schemas as memory_sync_status_schemas, }; use openhuman_core::openhuman::memory::tool_memory::prompt::{ - TOOL_MEMORY_HEADING, ToolMemoryRulesSection, render_tool_memory_rules, + render_tool_memory_rules, ToolMemoryRulesSection, TOOL_MEMORY_HEADING, }; use openhuman_core::openhuman::memory::tool_memory::{ - TOOL_MEMORY_PROMPT_CAP, ToolMemoryPriority, ToolMemoryRule, ToolMemorySource, - tool_memory_namespace, tool_memory_store, + tool_memory_namespace, tool_memory_store, ToolMemoryPriority, ToolMemoryRule, ToolMemorySource, + TOOL_MEMORY_PROMPT_CAP, }; use openhuman_core::openhuman::memory::tools::tool_memory::{ MemoryToolsListTool, MemoryToolsPutTool, @@ -88,47 +104,34 @@ use openhuman_core::openhuman::memory::tree::score::extract::{ }; use openhuman_core::openhuman::memory::tree::score::resolver::CanonicalEntity; use openhuman_core::openhuman::memory::tree::score::signals::{ - ScoreSignals, SignalWeights, combine, combine_cheap_only, compute as compute_score_signals, - entity_density_score, interaction, metadata_weight, source_weight, token_count, unique_words, + combine, combine_cheap_only, compute as compute_score_signals, entity_density_score, + interaction, metadata_weight, source_weight, token_count, unique_words, ScoreSignals, + SignalWeights, }; use openhuman_core::openhuman::memory::tree::score::store as score_store; -use openhuman_core::openhuman::memory::tree::score::{ScoringConfig, resolver}; +use openhuman_core::openhuman::memory::tree::score::{resolver, ScoringConfig}; use openhuman_core::openhuman::memory::tree::summarise::{ - SummaryContext, SummaryInput, fallback_summary, + fallback_summary, SummaryContext, SummaryInput, }; use openhuman_core::openhuman::memory::tree::tree::bucket_seal::LeafRef; use openhuman_core::openhuman::memory::tree::tree_runtime::store as tree_runtime_store; use openhuman_core::openhuman::memory::tree::tree_runtime::{ - NodeLevel, TreeNode, all_tree_summarizer_controller_schemas, - all_tree_summarizer_registered_controllers, derive_node_ids, derive_parent_id, estimate_tokens, - level_from_node_id, node_id_to_path, + all_tree_summarizer_controller_schemas, all_tree_summarizer_registered_controllers, + derive_node_ids, derive_parent_id, estimate_tokens, level_from_node_id, node_id_to_path, + NodeLevel, TreeNode, }; use openhuman_core::openhuman::memory::tree::{retrieval, score::embed}; +use tinymemory_core::tree_policy::TreePolicy; +use tinymemory_core::tree_source; use openhuman_core::openhuman::memory::{ - MemoryIngestionConfig, MemoryIngestionRequest, all_memory_controller_schemas, - all_memory_registered_controllers, + all_memory_controller_schemas, all_memory_registered_controllers, preferences::{ - USER_PREF_GENERAL_NAMESPACE, USER_PREF_SITUATIONAL_NAMESPACE, load_general_preferences, - recall_related_preferences, recall_situational_preferences, + load_general_preferences, recall_related_preferences, recall_situational_preferences, + USER_PREF_GENERAL_NAMESPACE, USER_PREF_SITUATIONAL_NAMESPACE, }, read_rpc as memory_read_rpc, + MemoryIngestionConfig, MemoryIngestionRequest, }; -use tinymemory_core::queue::types::ReembedBackfillPayload; -use tinymemory_core::queue::{ - self as memory_queue, AppendBufferPayload, AppendTarget, DEFAULT_LOCK_DURATION_MS, - ExtractChunkPayload, FlushStalePayload, JobKind, JobStatus, NewJob, NodeRef, SealPayload, -}; -use tinymemory_core::store::chunks::store::{upsert_chunks, with_connection}; -use tinymemory_core::store::chunks::types::{ - Chunk, DataSource, Metadata, SourceKind as ChunkSourceKind, SourceRef, approx_token_count, - chunk_id, -}; -use tinymemory_core::store::trees::types::{ - SummaryNode, Tree, TreeKind, TreeStatus as StoredTreeStatus, -}; -use tinymemory_core::store::{MemoryClient, NamespaceDocumentInput, UnifiedMemory}; -use tinymemory_core::tree_policy::TreePolicy; -use tinymemory_core::tree_source; // `remember`, `rpc_models`, `traits` and `util` moved into the extracted engine // crate with the rest of the memory implementation; the host re-exports some of // their contents flat but not the modules themselves. @@ -147,8 +150,13 @@ use openhuman_core::openhuman::memory::rpc_models::{ UpdateConversationMessageRequest, UpdateConversationThreadLabelsRequest, UpdateConversationThreadTitleRequest, UpsertConversationThreadRequest, WriteMemoryFileRequest, }; +use tinymemory_core::{ + remember::RememberSourceKind, + rpc_models::{ApiEnvelope, ApiError, ApiMeta, PaginationMeta, QueryNamespaceRequest, RecallContextRequest, RecallMemoriesRequest}, + traits::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts}, + util::redact::{redact, redact_endpoint}, +}; use openhuman_core::openhuman::security::{AutonomyLevel, SecurityPolicy}; -use openhuman_core::openhuman::threads::ThreadsError; use openhuman_core::openhuman::threads::ops as thread_ops; use openhuman_core::openhuman::threads::title::{ build_title_prompt, collapse_whitespace, is_auto_generated_thread_title, @@ -159,30 +167,22 @@ use openhuman_core::openhuman::threads::turn_state::{ SubagentActivity, SubagentToolCall, ToolTimelineEntry, ToolTimelineStatus, TurnLifecycle, TurnPhase, TurnState, TurnStateMirror, TurnStateStore, }; +use openhuman_core::openhuman::threads::ThreadsError; use openhuman_core::openhuman::threads::{ all_threads_controller_schemas, all_threads_registered_controllers, }; use openhuman_core::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory}; use tinycortex::memory::ingest::canonicalize::chat::{ - ChatBatch, ChatMessage, canonicalise as canonicalise_chat, + canonicalise as canonicalise_chat, ChatBatch, ChatMessage, }; use tinycortex::memory::ingest::canonicalize::document::{ - DocumentInput, canonicalise as canonicalise_document, + canonicalise as canonicalise_document, DocumentInput, }; use tinycortex::memory::ingest::canonicalize::email::{ - EmailMessage, EmailThread, canonicalise as canonicalise_email, + canonicalise as canonicalise_email, EmailMessage, EmailThread, }; use tinycortex::memory::ingest::canonicalize::email_clean; use tinycortex::memory::sync::{SyncOutcome as PipelineSyncOutcome, SyncPipelineKind}; -use tinymemory_core::{ - remember::RememberSourceKind, - rpc_models::{ - ApiEnvelope, ApiError, ApiMeta, PaginationMeta, QueryNamespaceRequest, - RecallContextRequest, RecallMemoriesRequest, - }, - traits::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts}, - util::redact::{redact, redact_endpoint}, -}; struct EnvVarGuard { key: &'static str, @@ -512,20 +512,16 @@ Kitchen is north of Garden. assert!(result.relation_count >= 8); assert!(result.preference_count >= 1); assert!(result.decision_count >= 2); - assert!( - result - .entities - .iter() - .any(|entity| entity.name == "ALICE MORGAN") - ); - assert!( - result - .relations - .iter() - .any(|relation| relation.subject.contains("OPENHUMAN") - && relation.predicate == "USES" - && relation.object.contains("TEXT-EMBEDDING")) - ); + assert!(result + .entities + .iter() + .any(|entity| entity.name == "ALICE MORGAN")); + assert!(result + .relations + .iter() + .any(|relation| relation.subject.contains("OPENHUMAN") + && relation.predicate == "USES" + && relation.object.contains("TEXT-EMBEDDING"))); let rows = memory .graph_query_namespace("memory-raw-ingestion", Some("ALICE MORGAN"), Some("OWNS")) @@ -541,23 +537,19 @@ Kitchen is north of Garden. ) .await .expect("query context"); - assert!( - context - .hits - .iter() - .flat_map(|hit| hit.supporting_relations.iter()) - .any(|relation| relation.predicate == "OWNS" || relation.predicate == "USES") - ); + assert!(context + .hits + .iter() + .flat_map(|hit| hit.supporting_relations.iter()) + .any(|relation| relation.predicate == "OWNS" || relation.predicate == "USES")); let recall = memory .recall_namespace_memories("memory-raw-ingestion", 5) .await .expect("recall memories"); - assert!( - recall - .iter() - .any(|hit| hit.document_id.as_deref() == Some("doc-memory-raw-ingestion")) - ); + assert!(recall + .iter() + .any(|hit| hit.document_id.as_deref() == Some("doc-memory-raw-ingestion"))); let extract_again = memory .extract_graph( @@ -646,13 +638,11 @@ async fn memory_source_readers_validate_and_use_local_inputs_only() { let mut twitter = source(SourceKind::TwitterQuery, "src_tw"); twitter.query = Some("AI safety".into()); - assert!( - reader_for(&SourceKind::TwitterQuery) - .list_items(&twitter, &config) - .await - .unwrap_err() - .contains("not yet configured") - ); + assert!(reader_for(&SourceKind::TwitterQuery) + .list_items(&twitter, &config) + .await + .unwrap_err() + .contains("not yet configured")); let mut composio = source(SourceKind::Composio, "src_cmp"); composio.toolkit = Some("gmail".into()); @@ -666,14 +656,12 @@ async fn memory_source_readers_validate_and_use_local_inputs_only() { .title, "gmail connection" ); - assert!( - composio_reader - .read_item(&composio, "conn-1", &config) - .await - .expect("composio read") - .body - .contains("provider sync pipeline") - ); + assert!(composio_reader + .read_item(&composio, "conn-1", &config) + .await + .expect("composio read") + .body + .contains("provider sync pipeline")); for (kind, expected) in [ (SourceKind::Composio, "composio"), @@ -685,52 +673,40 @@ async fn memory_source_readers_validate_and_use_local_inputs_only() { ] { assert_eq!(kind.as_str(), expected); } - assert!( - source(SourceKind::GithubRepo, "bad") - .validate() - .unwrap_err() - .contains("url") - ); - assert!( - source(SourceKind::TwitterQuery, "bad") - .validate() - .unwrap_err() - .contains("query") - ); + assert!(source(SourceKind::GithubRepo, "bad") + .validate() + .unwrap_err() + .contains("url")); + assert!(source(SourceKind::TwitterQuery, "bad") + .validate() + .unwrap_err() + .contains("query")); let mut github = source(SourceKind::GithubRepo, "src_github"); github.url = Some("https://github.com/tinyhumansai/openhuman".into()); let github_reader = reader_for(&SourceKind::GithubRepo); assert_eq!(github_reader.kind(), SourceKind::GithubRepo); - assert!( - github_reader - .read_item(&github, "unknown:123", &config) - .await - .unwrap_err() - .contains("invalid item id") - ); - assert!( - github_reader - .read_item(&github, "issue:not-a-number", &config) - .await - .unwrap_err() - .contains("invalid issue number") - ); - assert!( - github_reader - .read_item(&github, "pr:not-a-number", &config) - .await - .unwrap_err() - .contains("invalid PR number") - ); + assert!(github_reader + .read_item(&github, "unknown:123", &config) + .await + .unwrap_err() + .contains("invalid item id")); + assert!(github_reader + .read_item(&github, "issue:not-a-number", &config) + .await + .unwrap_err() + .contains("invalid issue number")); + assert!(github_reader + .read_item(&github, "pr:not-a-number", &config) + .await + .unwrap_err() + .contains("invalid PR number")); github.url = Some("https://github.com/tinyhumansai/openhuman/tree/main".into()); - assert!( - github_reader - .list_items(&github, &config) - .await - .unwrap_err() - .contains("expected https://github.com//") - ); + assert!(github_reader + .list_items(&github, &config) + .await + .unwrap_err() + .contains("expected https://github.com//")); } #[tokio::test] @@ -826,23 +802,19 @@ async fn memory_thread_tree_and_sync_controller_schemas_execute_public_handlers( thread_schemas.len() ); for function in expected_thread_functions { - assert!( - thread_schemas - .iter() - .any(|schema| schema.namespace == "threads" && schema.function == function) - ); + assert!(thread_schemas + .iter() + .any(|schema| schema.namespace == "threads" && schema.function == function)); } let thread_upsert = thread_controllers .iter() .find(|controller| controller.schema.function == "upsert") .expect("threads upsert controller"); - assert!( - (thread_upsert.handler)(Map::new()) - .await - .unwrap_err() - .contains("invalid params") - ); + assert!((thread_upsert.handler)(Map::new()) + .await + .unwrap_err() + .contains("invalid params")); let task_board_put = thread_controllers .iter() @@ -890,12 +862,10 @@ async fn memory_thread_tree_and_sync_controller_schemas_execute_public_handlers( .iter() .find(|schema| schema.function == "ingest") .expect("ingest schema"); - assert!( - ingest_schema - .inputs - .iter() - .any(|field| field.name == "metadata" && !field.required) - ); + assert!(ingest_schema + .inputs + .iter() + .any(|field| field.name == "metadata" && !field.required)); let tree_status = tree_controllers .iter() @@ -915,12 +885,10 @@ async fn memory_thread_tree_and_sync_controller_schemas_execute_public_handlers( bad_ingest.insert("namespace".into(), json!("schema_handlers")); bad_ingest.insert("content".into(), json!("content")); bad_ingest.insert("timestamp".into(), json!(123)); - assert!( - (tree_ingest.handler)(bad_ingest) - .await - .unwrap_err() - .contains("expected string") - ); + assert!((tree_ingest.handler)(bad_ingest) + .await + .unwrap_err() + .contains("expected string")); let sync_schemas = memory_sync_status_schemas::all_controller_schemas(); let sync_controllers = memory_sync_status_schemas::all_registered_controllers(); @@ -962,13 +930,11 @@ async fn memory_thread_tree_and_sync_controller_schemas_execute_public_handlers( let status_json = (sync_controllers[0].handler)(Map::new()) .await .expect("sync status controller"); - assert!( - status_json["statuses"] - .as_array() - .unwrap() - .iter() - .any(|row| row["provider"] == "slack") - ); + assert!(status_json["statuses"] + .as_array() + .unwrap() + .iter() + .any(|row| row["provider"] == "slack")); let slack_schemas = slack_memory_schemas::all_slack_memory_controller_schemas(); let slack_controllers = slack_memory_schemas::all_slack_memory_registered_controllers(); @@ -981,12 +947,10 @@ async fn memory_thread_tree_and_sync_controller_schemas_execute_public_handlers( .expect("slack sync trigger controller"); let mut bad_trigger = Map::new(); bad_trigger.insert("connection_id".into(), json!(123)); - assert!( - (trigger.handler)(bad_trigger) - .await - .unwrap_err() - .contains("invalid params") - ); + assert!((trigger.handler)(bad_trigger) + .await + .unwrap_err() + .contains("invalid params")); } #[test] @@ -1034,11 +998,9 @@ fn memory_schema_registries_and_query_tool_metadata_cover_public_surfaces() { let schema = openhuman_core::openhuman::memory::schemas::schemas(function); assert_eq!(schema.namespace, "memory"); assert_eq!(schema.function, function); - assert!( - memory_schemas - .iter() - .any(|candidate| candidate.function == function) - ); + assert!(memory_schemas + .iter() + .any(|candidate| candidate.function == function)); } assert_eq!( openhuman_core::openhuman::memory::schemas::schemas("missing").function, @@ -1078,11 +1040,9 @@ fn memory_schema_registries_and_query_tool_metadata_cover_public_surfaces() { let schema = openhuman_core::openhuman::memory::schema::schemas(function); assert_eq!(schema.namespace, "memory_tree"); assert_eq!(schema.function, function); - assert!( - legacy_tree_schemas - .iter() - .any(|candidate| candidate.function == function) - ); + assert!(legacy_tree_schemas + .iter() + .any(|candidate| candidate.function == function)); } assert_eq!( openhuman_core::openhuman::memory::schema::schemas("missing").function, @@ -1094,13 +1054,11 @@ fn memory_schema_registries_and_query_tool_metadata_cover_public_surfaces() { assert_eq!(consolidated.name(), "memory_tree"); assert_eq!(consolidated.category(), ToolCategory::System); assert_eq!(consolidated.permission_level(), PermissionLevel::ReadOnly); - assert!( - schema["properties"]["mode"]["enum"] - .as_array() - .unwrap() - .iter() - .any(|mode| mode == "walk") - ); + assert!(schema["properties"]["mode"]["enum"] + .as_array() + .unwrap() + .iter() + .any(|mode| mode == "walk")); for tool in [ &MemoryTreeSearchEntitiesTool as &dyn Tool, @@ -1306,11 +1264,9 @@ fn memory_sync_composio_catalog_scope_and_state_helpers_cover_edge_cases() { ); assert!(toolkit_has_scope("gmail", ToolScope::Admin)); assert!(catalog_for_toolkit("google_calendar").is_some()); - assert!( - agent_ready_toolkits() - .windows(2) - .all(|pair| pair[0] <= pair[1]) - ); + assert!(agent_ready_toolkits() + .windows(2) + .all(|pair| pair[0] <= pair[1])); let matrix = capability_matrix(); let gmail = matrix.iter().find(|cap| cap.toolkit == "gmail").unwrap(); @@ -1369,11 +1325,9 @@ fn slack_memory_schemas_and_post_processors_normalize_composio_shapes() { let schemas = slack_memory_schemas::all_slack_memory_controller_schemas(); assert_eq!(schemas.len(), 2); assert_eq!(schemas[0].namespace, "slack_memory"); - assert!( - schemas - .iter() - .any(|schema| schema.function == "sync_status" && schema.inputs.is_empty()) - ); + assert!(schemas + .iter() + .any(|schema| schema.function == "sync_status" && schema.inputs.is_empty())); let mut history = json!({ "data": { @@ -1448,18 +1402,14 @@ fn memory_tree_scoring_signal_helpers_cover_boundaries_and_serialization() { let regex_entities = openhuman_core::openhuman::memory::tree::score::extract::regex::extract( "Alice emailed bob@example.com from https://example.test and mentioned #coverage.", ); - assert!( - regex_entities - .entities - .iter() - .any(|entity| entity.kind == EntityKind::Email && entity.text == "bob@example.com") - ); + assert!(regex_entities + .entities + .iter() + .any(|entity| entity.kind == EntityKind::Email && entity.text == "bob@example.com")); let canonical = resolver::canonicalise(®ex_entities); - assert!( - canonical - .iter() - .any(|entity| entity.canonical_id == "email:bob@example.com") - ); + assert!(canonical + .iter() + .any(|entity| entity.canonical_id == "email:bob@example.com")); assert_eq!( resolver::canonical_id_for(EntityKind::Url, "https://Example.test/path/"), "url:https://Example.test/path/" @@ -1611,11 +1561,9 @@ fn memory_tree_runtime_store_buffers_and_retrieval_wire_helpers() { .summary, "Workspace root summary" ); - assert!( - tree_runtime_store::read_node(&config, namespace, "missing") - .unwrap() - .is_none() - ); + assert!(tree_runtime_store::read_node(&config, namespace, "missing") + .unwrap() + .is_none()); assert_eq!( tree_runtime_store::read_children(&config, namespace, "root") .unwrap() @@ -1680,11 +1628,9 @@ fn memory_tree_runtime_store_buffers_and_retrieval_wire_helpers() { .expect("buffer write second"); let buffered = tree_runtime_store::buffer_read(&config, namespace).expect("buffer read"); assert_eq!(buffered.len(), 2); - assert!( - buffered - .iter() - .any(|(_, body)| body == "first buffered body") - ); + assert!(buffered + .iter() + .any(|(_, body)| body == "first buffered body")); tree_runtime_store::buffer_delete( &config, namespace, @@ -1699,11 +1645,9 @@ fn memory_tree_runtime_store_buffers_and_retrieval_wire_helpers() { assert!(second_buffer.exists()); let drained = tree_runtime_store::buffer_drain(&config, namespace).expect("buffer drain"); assert_eq!(drained.len(), 1); - assert!( - tree_runtime_store::buffer_read(&config, namespace) - .unwrap() - .is_empty() - ); + assert!(tree_runtime_store::buffer_read(&config, namespace) + .unwrap() + .is_empty()); assert_eq!(NodeLevel::Hour.max_tokens(), 1_000); assert_eq!(NodeLevel::Month.parent_level(), Some(NodeLevel::Year)); @@ -1940,19 +1884,15 @@ async fn memory_read_rpc_score_index_and_summary_helpers_cover_dashboard_paths() .expect("score breakdown"); assert!(breakdown.kept); assert!(!breakdown.llm_consulted); - assert!( - !breakdown - .signals - .iter() - .any(|signal| signal.name == "llm_importance" && signal.weight == 2.0) - ); - assert!( - memory_read_rpc::chunk_score_rpc(&config, "missing".into()) - .await - .expect("missing chunk score") - .value - .is_none() - ); + assert!(!breakdown + .signals + .iter() + .any(|signal| signal.name == "llm_importance" && signal.weight == 2.0)); + assert!(memory_read_rpc::chunk_score_rpc(&config, "missing".into()) + .await + .expect("missing chunk score") + .value + .is_none()); let missing_delete = memory_read_rpc::delete_chunk_rpc(&config, "missing".into()) .await @@ -2166,8 +2106,7 @@ async fn memory_preferences_remember_redaction_and_pipeline_traits_cover_public_ // are host policy over a driver, not engine calls. `guarded_in_memory` // gives a real guard over a real store, so this still exercises the // decorator production uses rather than reaching past it. - let (_provider, memory) = - openhuman_core::openhuman::memory::guard::in_memory::guarded_in_memory(); + let (_provider, memory) = openhuman_core::openhuman::memory::guard::in_memory::guarded_in_memory(); memory .store( @@ -2206,16 +2145,12 @@ async fn memory_preferences_remember_redaction_and_pipeline_traits_cover_public_ let general = load_general_preferences(&memory, 10).await; assert_eq!(general, vec!["Prefer concise responses."]); assert!(load_general_preferences(&memory, 0).await.is_empty()); - assert!( - recall_situational_preferences(&memory, " ") - .await - .is_empty() - ); - assert!( - recall_related_preferences(&memory, " ", "tone", 3) - .await - .is_empty() - ); + assert!(recall_situational_preferences(&memory, " ") + .await + .is_empty()); + assert!(recall_related_preferences(&memory, " ", "tone", 3) + .await + .is_empty()); assert!( recall_related_preferences(&memory, "Prefer concise responses.", "tone", 0) .await @@ -2264,13 +2199,11 @@ async fn memory_tools_and_user_scope_prefs_cover_public_execution_paths() { let store_tool = MemoryStoreTool::new(security.clone()); assert_eq!(store_tool.name(), "memory_store"); - assert!( - store_tool.parameters_schema()["required"] - .as_array() - .unwrap() - .iter() - .any(|field| field == "content") - ); + assert!(store_tool.parameters_schema()["required"] + .as_array() + .unwrap() + .iter() + .any(|field| field == "content")); let stored = store_tool .execute(json!({ "namespace": "coverage-tools", @@ -2328,14 +2261,12 @@ async fn memory_tools_and_user_scope_prefs_cover_public_execution_paths() { .expect("recall tool"); assert!(!recalled.is_error); assert!(recalled.output().contains("rust")); - assert!( - recall_tool - .execute(json!({ "namespace": "coverage-tools", "query": " " })) - .await - .unwrap_err() - .to_string() - .contains("query cannot be empty") - ); + assert!(recall_tool + .execute(json!({ "namespace": "coverage-tools", "query": " " })) + .await + .unwrap_err() + .to_string() + .contains("query cannot be empty")); let forget_tool = MemoryForgetTool::new(security); assert_eq!(forget_tool.name(), "memory_forget"); @@ -2381,12 +2312,10 @@ async fn memory_tools_and_user_scope_prefs_cover_public_execution_paths() { user_scopes::load(&scoped_client, "gmail").await, UserScopePref::default() ); - assert!( - user_scopes::save(&scoped_client, " ", pref) - .await - .unwrap_err() - .contains("toolkit must not be empty") - ); + assert!(user_scopes::save(&scoped_client, " ", pref) + .await + .unwrap_err() + .contains("toolkit must not be empty")); assert_eq!( user_scopes::load_or_default("not-ready-toolkit").await, UserScopePref::default() @@ -2444,16 +2373,12 @@ async fn memory_queue_and_tool_memory_public_stores_cover_persistence_edges() { }, }; assert_eq!(extract.dedupe_key(), "extract:chunk-tool-memory"); - assert!( - source_append - .dedupe_key() - .contains("append:source:slack:#raw:leaf:chunk-tool-memory") - ); - assert!( - topic_append - .dedupe_key() - .contains("append:topic:topic:raw:summary:summary-tool-memory") - ); + assert!(source_append + .dedupe_key() + .contains("append:source:slack:#raw:leaf:chunk-tool-memory")); + assert!(topic_append + .dedupe_key() + .contains("append:topic:topic:raw:summary:summary-tool-memory")); assert_eq!( SealPayload { tree_id: "tree-1".into(), @@ -2482,11 +2407,9 @@ async fn memory_queue_and_tool_memory_public_stores_cover_persistence_edges() { let first_id = memory_queue::enqueue(&config, &first_job) .expect("enqueue") .expect("inserted"); - assert!( - memory_queue::enqueue(&config, &first_job) - .expect("dedupe enqueue") - .is_none() - ); + assert!(memory_queue::enqueue(&config, &first_job) + .expect("dedupe enqueue") + .is_none()); assert_eq!(memory_queue::count_total(&config).unwrap(), 1); assert_eq!( memory_queue::count_by_status(&config, JobStatus::Ready).unwrap(), @@ -2552,32 +2475,28 @@ async fn memory_queue_and_tool_memory_public_stores_cover_persistence_edges() { assert!(ToolMemoryPriority::High.is_eager()); assert!(!ToolMemoryPriority::Normal.is_eager()); assert_eq!(ToolMemorySource::default(), ToolMemorySource::Programmatic); - assert!( - store - .record( - " ", - "blank tool rejected", - ToolMemoryPriority::High, - ToolMemorySource::UserExplicit, - Vec::new(), - ) - .await - .unwrap_err() - .contains("tool_name") - ); - assert!( - store - .record( - "shell", - " ", - ToolMemoryPriority::High, - ToolMemorySource::UserExplicit, - Vec::new(), - ) - .await - .unwrap_err() - .contains("rule body") - ); + assert!(store + .record( + " ", + "blank tool rejected", + ToolMemoryPriority::High, + ToolMemorySource::UserExplicit, + Vec::new(), + ) + .await + .unwrap_err() + .contains("tool_name")); + assert!(store + .record( + "shell", + " ", + ToolMemoryPriority::High, + ToolMemorySource::UserExplicit, + Vec::new(), + ) + .await + .unwrap_err() + .contains("rule body")); let critical = store .record( @@ -2639,11 +2558,9 @@ async fn memory_queue_and_tool_memory_public_stores_cover_persistence_edges() { .rules_for_prompt(&[]) .await .expect("prompt rules from namespaces"); - assert!( - prompt_rules["shell"] - .iter() - .all(|rule| rule.priority.is_eager()) - ); + assert!(prompt_rules["shell"] + .iter() + .all(|rule| rule.priority.is_eager())); assert_eq!(TOOL_MEMORY_PROMPT_CAP, 30); let render_rules: Vec = [normal.clone(), updated.clone(), high.clone()] .into_iter() @@ -2658,55 +2575,43 @@ async fn memory_queue_and_tool_memory_public_stores_cover_persistence_edges() { assert!(rendered.contains("### `shell`")); assert!(ToolMemoryRulesSection::empty().is_empty()); assert!(!ToolMemoryRulesSection::new(vec![updated.clone()]).is_empty()); - assert!( - store - .delete_rule("shell", &normal.id) - .await - .expect("delete normal") - ); - assert!( - !store - .delete_rule("shell", &normal.id) - .await - .expect("delete missing") - ); - assert!( - store - .get_rule("shell", &normal.id) - .await - .expect("missing normal") - .is_none() - ); + assert!(store + .delete_rule("shell", &normal.id) + .await + .expect("delete normal")); + assert!(!store + .delete_rule("shell", &normal.id) + .await + .expect("delete missing")); + assert!(store + .get_rule("shell", &normal.id) + .await + .expect("missing normal") + .is_none()); let put_tool = MemoryToolsPutTool; assert_eq!(put_tool.name(), "memory_tools_put"); assert_eq!(put_tool.category(), ToolCategory::System); - assert!( - put_tool.parameters_schema()["required"] - .as_array() - .unwrap() - .iter() - .any(|field| field == "rule") - ); - assert!( - put_tool - .execute(json!({ "tool_name": "shell" })) - .await - .unwrap_err() - .to_string() - .contains("invalid arguments for memory_tools_put") - ); + assert!(put_tool.parameters_schema()["required"] + .as_array() + .unwrap() + .iter() + .any(|field| field == "rule")); + assert!(put_tool + .execute(json!({ "tool_name": "shell" })) + .await + .unwrap_err() + .to_string() + .contains("invalid arguments for memory_tools_put")); let list_tool = MemoryToolsListTool; assert_eq!(list_tool.name(), "memory_tools_list"); assert_eq!(list_tool.permission_level(), PermissionLevel::ReadOnly); - assert!( - list_tool - .execute(json!({})) - .await - .unwrap_err() - .to_string() - .contains("invalid arguments for memory_tools_list") - ); + assert!(list_tool + .execute(json!({})) + .await + .unwrap_err() + .to_string() + .contains("invalid arguments for memory_tools_list")); assert_eq!( ToolMemoryRule::storage_key(&updated.id), format!("rule/{}", updated.id) @@ -2727,12 +2632,10 @@ async fn memory_source_sync_entrypoint_rejects_disabled_and_ingests_folder_items let mut disabled = source(SourceKind::Folder, "src_disabled"); disabled.path = Some(tmp.path().to_string_lossy().to_string()); disabled.enabled = false; - assert!( - sync_source(disabled, Arc::new(config.clone())) - .await - .unwrap_err() - .contains("disabled") - ); + assert!(sync_source(disabled, Arc::new(config.clone())) + .await + .unwrap_err() + .contains("disabled")); let mut folder = source(SourceKind::Folder, "src_sync"); folder.path = Some(tmp.path().to_string_lossy().to_string()); @@ -3037,11 +2940,9 @@ fn gmail_post_processor_and_provider_registry_cover_public_edges() { init_default_composio_providers(); assert!(get_provider(" gmail ").is_some()); assert!(get_provider("unknown_provider_slug").is_none()); - assert!( - all_composio_providers() - .iter() - .any(|provider| provider.toolkit_slug() == "slack") - ); + assert!(all_composio_providers() + .iter() + .any(|provider| provider.toolkit_slug() == "slack")); register_provider(Arc::new(RawCoverageProvider { fail_profile: false, })); @@ -3116,7 +3017,8 @@ async fn memory_sync_provider_trait_defaults_and_connection_hook_are_determinist // unready client and see 0 instead of 1. Bind the global to this test's // workspace up front so the assertion is independent of execution order. ensure_memory_seams(); - tinymemory_core::global::init(tmp.path().to_path_buf()).expect("init global memory client"); + tinymemory_core::global::init(tmp.path().to_path_buf()) + .expect("init global memory client"); let ctx = ProviderContext { config: Arc::new(config_in(&tmp)), toolkit: "raw_coverage".into(), @@ -3128,13 +3030,11 @@ async fn memory_sync_provider_trait_defaults_and_connection_hook_are_determinist let provider = RawCoverageProvider { fail_profile: true }; assert_eq!(provider.sync_interval_secs(), Some(15 * 60)); assert!(provider.curated_tools().is_none()); - assert!( - provider - .fetch_tasks(&ctx, &TaskFetchFilter::default()) - .await - .unwrap_err() - .contains("provider has no task-fetch surface") - ); + assert!(provider + .fetch_tasks(&ctx, &TaskFetchFilter::default()) + .await + .unwrap_err() + .contains("provider has no task-fetch surface")); let mut action_data = json!({ "ok": true }); provider.post_process_action_result("RAW_ACTION", None, &mut action_data); @@ -3188,12 +3088,10 @@ fn turn_state_mirror_persists_progress_edges_from_public_events() { let tmp = TempDir::new().expect("tempdir"); let store = TurnStateStore::new(tmp.path().to_path_buf()); let mut mirror = TurnStateMirror::new(store.clone(), "thread/mirror", "request-mirror"); - assert!( - store - .get("thread/mirror") - .expect("initial snapshot") - .is_some() - ); + assert!(store + .get("thread/mirror") + .expect("initial snapshot") + .is_some()); assert!(mirror.observe(&AgentProgress::TurnStarted)); assert!(mirror.observe(&AgentProgress::IterationStarted { @@ -3330,12 +3228,10 @@ fn turn_state_mirror_persists_progress_edges_from_public_events() { assert_eq!(snapshot.streaming_text, "visible"); assert_eq!(snapshot.thinking, "thinking "); assert_eq!(snapshot.task_board, Some(board)); - assert!( - snapshot - .tool_timeline - .iter() - .any(|entry| entry.id == "call-1" && entry.status == ToolTimelineStatus::Error) - ); + assert!(snapshot + .tool_timeline + .iter() + .any(|entry| entry.id == "call-1" && entry.status == ToolTimelineStatus::Error)); assert!(snapshot.tool_timeline.iter().any(|entry| { entry.id == "subagent:task-1" && entry.status == ToolTimelineStatus::Error @@ -3479,12 +3375,10 @@ fn memory_source_types_and_freshness_cover_validation_matrix() { let mut composio_source = source(SourceKind::Composio, "cmp"); assert!(composio_source.validate().unwrap_err().contains("toolkit")); composio_source.toolkit = Some("gmail".into()); - assert!( - composio_source - .validate() - .unwrap_err() - .contains("connection_id") - ); + assert!(composio_source + .validate() + .unwrap_err() + .contains("connection_id")); composio_source.connection_id = Some("conn-1".into()); assert!(composio_source.validate().is_ok()); @@ -3576,11 +3470,9 @@ fn turn_state_store_persists_lists_marks_and_clears_snapshots() { .as_deref(), Some("research") ); - assert!( - turn_state::store::get(workspace.clone(), "missing") - .unwrap() - .is_none() - ); + assert!(turn_state::store::get(workspace.clone(), "missing") + .unwrap() + .is_none()); let mut listed = turn_state::store::list(workspace.clone()).expect("list states"); listed.sort_by(|a, b| a.thread_id.cmp(&b.thread_id)); @@ -3759,12 +3651,10 @@ async fn threads_rpc_ops_cover_crud_title_fallback_and_turn_state_cleanup() { .data .expect("threads"); assert!(all_threads.count >= 2); - assert!( - all_threads - .threads - .iter() - .any(|thread| thread.title == "Manual coverage title") - ); + assert!(all_threads + .threads + .iter() + .any(|thread| thread.title == "Manual coverage title")); let mut turn = TurnState::started("thread/raw-crud", "request-raw", 3, "2026-05-29T12:02:00Z"); turn.lifecycle = TurnLifecycle::Streaming; @@ -3810,11 +3700,9 @@ async fn threads_rpc_ops_cover_crud_title_fallback_and_turn_state_cleanup() { .data .expect("delete response"); assert!(deleted.deleted); - assert!( - turn_state::store::get(workspace_dir, "thread/raw-crud") - .unwrap() - .is_none() - ); + assert!(turn_state::store::get(workspace_dir, "thread/raw-crud") + .unwrap() + .is_none()); let purged = thread_ops::threads_purge(EmptyRequest {}) .await @@ -3923,12 +3811,10 @@ async fn memory_sources_registry_rpc_and_schema_handlers_cover_crud_edges() { .expect("add controller"); let mut bad_params = Map::new(); bad_params.insert("kind".into(), Value::String("folder".into())); - assert!( - (add_controller.handler)(bad_params) - .await - .unwrap_err() - .contains("missing field `label`") - ); + assert!((add_controller.handler)(bad_params) + .await + .unwrap_err() + .contains("missing field `label`")); let invalid_folder = memory_sources_rpc::add_rpc(memory_sources_rpc::AddRequest { kind: SourceKind::Folder, @@ -3983,32 +3869,30 @@ async fn memory_sources_registry_rpc_and_schema_handlers_cover_crud_edges() { .value .source; assert_eq!(added.kind, SourceKind::Folder); - assert!( - memory_sources_rpc::add_rpc(memory_sources_rpc::AddRequest { - kind: SourceKind::Folder, - label: "Duplicate".into(), - enabled: true, - toolkit: None, - connection_id: None, - path: Some(tmp.path().to_string_lossy().to_string()), - glob: None, - url: None, - branch: None, - paths: Vec::new(), - query: None, - since_days: None, - max_items: None, - max_commits: None, - max_issues: None, - max_prs: None, - selector: None, - max_tokens_per_sync: None, - max_cost_per_sync_usd: None, - sync_depth_days: None, - }) - .await - .is_ok() - ); + assert!(memory_sources_rpc::add_rpc(memory_sources_rpc::AddRequest { + kind: SourceKind::Folder, + label: "Duplicate".into(), + enabled: true, + toolkit: None, + connection_id: None, + path: Some(tmp.path().to_string_lossy().to_string()), + glob: None, + url: None, + branch: None, + paths: Vec::new(), + query: None, + since_days: None, + max_items: None, + max_commits: None, + max_issues: None, + max_prs: None, + selector: None, + max_tokens_per_sync: None, + max_cost_per_sync_usd: None, + sync_depth_days: None, + }) + .await + .is_ok()); let enabled_folders = registry::list_enabled_by_kind(SourceKind::Folder) .await @@ -4026,16 +3910,14 @@ async fn memory_sources_registry_rpc_and_schema_handlers_cover_crud_edges() { .label, "Folder source" ); - assert!( - memory_sources_rpc::get_rpc(memory_sources_rpc::GetRequest { - id: "missing".into(), - }) - .await - .expect("get missing") - .value - .source - .is_none() - ); + assert!(memory_sources_rpc::get_rpc(memory_sources_rpc::GetRequest { + id: "missing".into(), + }) + .await + .expect("get missing") + .value + .source + .is_none()); let list_items = memory_sources_rpc::list_items_rpc(memory_sources_rpc::ListItemsRequest { source_id: added.id.clone(), @@ -4280,13 +4162,11 @@ async fn memory_ops_public_handlers_cover_document_file_kv_graph_and_envelopes_b .await .expect("doc list") .value; - assert!( - direct_docs["documents"] - .as_array() - .unwrap() - .iter() - .any(|doc| doc["documentId"] == "doc-ops-raw") - ); + assert!(direct_docs["documents"] + .as_array() + .unwrap() + .iter() + .any(|doc| doc["documentId"] == "doc-ops-raw")); let envelope_docs = openhuman_core::openhuman::memory::ops::memory_list_documents(ListDocumentsRequest { @@ -4448,13 +4328,11 @@ async fn memory_ops_public_handlers_cover_document_file_kv_graph_and_envelopes_b .await .expect("tool rules json") .value; - assert!( - tool_rules_json - .as_array() - .unwrap() - .iter() - .any(|rule| rule["id"] == "ops-rule-1" && rule["priority"] == "high") - ); + assert!(tool_rules_json + .as_array() + .unwrap() + .iter() + .any(|rule| rule["id"] == "ops-rule-1" && rule["priority"] == "high")); assert!( openhuman_core::openhuman::memory::ops::tool_rule_delete( openhuman_core::openhuman::memory::ops::ToolRuleRefParams { @@ -4466,18 +4344,16 @@ async fn memory_ops_public_handlers_cover_document_file_kv_graph_and_envelopes_b .expect("tool rule delete") .value ); - assert!( - openhuman_core::openhuman::memory::ops::tool_rule_get( - openhuman_core::openhuman::memory::ops::ToolRuleRefParams { - tool_name: "shell".into(), - id: "ops-rule-1".into(), - }, - ) - .await - .expect("tool rule missing") - .value - .is_none() - ); + assert!(openhuman_core::openhuman::memory::ops::tool_rule_get( + openhuman_core::openhuman::memory::ops::ToolRuleRefParams { + tool_name: "shell".into(), + id: "ops-rule-1".into(), + }, + ) + .await + .expect("tool rule missing") + .value + .is_none()); let delete_missing = openhuman_core::openhuman::memory::ops::memory_delete_document(DeleteDocumentRequest { @@ -4529,14 +4405,12 @@ async fn memory_tree_retrieval_rpc_and_schema_wrappers_cover_empty_and_invalid_p openhuman_core::openhuman::memory::tree::retrieval::schemas::schemas("missing").function, "unknown" ); - assert!( - schemas - .iter() - .find(|schema| schema.function == "fetch_leaves") - .unwrap() - .description - .contains("Batch-fetch") - ); + assert!(schemas + .iter() + .find(|schema| schema.function == "fetch_leaves") + .unwrap() + .description + .contains("Batch-fetch")); let source = openhuman_core::openhuman::memory::tree::retrieval::rpc::query_source_rpc( &config, @@ -4626,12 +4500,10 @@ async fn memory_tree_retrieval_rpc_and_schema_wrappers_cover_empty_and_invalid_p .expect("fetch controller"); let mut bad_params = Map::new(); bad_params.insert("chunk_ids".into(), json!("not-an-array")); - assert!( - (fetch_controller.handler)(bad_params) - .await - .unwrap_err() - .contains("invalid params") - ); + assert!((fetch_controller.handler)(bad_params) + .await + .unwrap_err() + .contains("invalid params")); } #[tokio::test] @@ -4813,18 +4685,14 @@ async fn memory_sources_types_registry_and_sync_state_cover_public_persistence_e assert_eq!(invalid.validate().unwrap_err(), "label is required"); invalid.label = "Missing path".into(); assert!(invalid.validate().unwrap_err().contains("path is required")); - assert!( - source(SourceKind::RssFeed, "rss_missing") - .validate() - .unwrap_err() - .contains("url is required") - ); - assert!( - source(SourceKind::WebPage, "web_missing") - .validate() - .unwrap_err() - .contains("url is required") - ); + assert!(source(SourceKind::RssFeed, "rss_missing") + .validate() + .unwrap_err() + .contains("url is required")); + assert!(source(SourceKind::WebPage, "web_missing") + .validate() + .unwrap_err() + .contains("url is required")); let mut entry = source(SourceKind::GithubRepo, "src_repo"); entry.url = Some("https://github.com/tinyhumansai/openhuman".into()); @@ -4832,12 +4700,10 @@ async fn memory_sources_types_registry_and_sync_state_cover_public_persistence_e .await .expect("add repo source"); assert_eq!(added.kind.as_str(), "github_repo"); - assert!( - registry::add_source(entry) - .await - .unwrap_err() - .contains("already exists") - ); + assert!(registry::add_source(entry) + .await + .unwrap_err() + .contains("already exists")); let patch: registry::MemorySourcePatch = serde_json::from_value(json!({ "label": "Updated repo", @@ -4875,7 +4741,8 @@ async fn memory_sources_types_registry_and_sync_state_cover_public_persistence_e MemoryClient::from_workspace_dir(tmp.path().join("memory-sync-state")) .expect("memory client"), ); - let adapter = tinymemory_core::tinycortex::HostSyncAdapter::new(memory.clone()); + let adapter = + tinymemory_core::tinycortex::HostSyncAdapter::new(memory.clone()); let fresh = SyncState::load(&adapter, "gmail", "conn-raw") .await .expect("fresh state"); @@ -4997,16 +4864,12 @@ fn welcome_migration_public_entrypoint_covers_empty_marker_and_transcript_paths( assert_eq!(result.transcripts_updated, 1); assert_eq!(result.transcript_files_renamed, 1); assert_eq!(result.markdown_files_renamed, 1); - assert!( - workspace - .join("session_raw/1715000000_orchestrator_thread-abc.jsonl") - .exists() - ); - assert!( - workspace - .join("sessions/2026_05_01/1715000000_orchestrator_thread-abc.md") - .exists() - ); + assert!(workspace + .join("session_raw/1715000000_orchestrator_thread-abc.jsonl") + .exists()); + assert!(workspace + .join("sessions/2026_05_01/1715000000_orchestrator_thread-abc.md") + .exists()); let second = openhuman_core::openhuman::threads::migrate_welcome_agent_artifacts(workspace) .expect("second migration"); From 44dd980c5245520f8d4ae17b7ea153f8c7842c49 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 23 Aug 2026 17:09:25 +0300 Subject: [PATCH 42/42] chore(tests): reformat import block in memory threads raw coverage test Reformatted the import of `tinymemory_core::rpc_models` to use a multi-line block style, improving readability and consistency with the project's formatting conventions. Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/raw_coverage/memory_threads_raw_coverage_e2e.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs index 1e3b33025f..4dc60f1abf 100644 --- a/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs +++ b/tests/raw_coverage/memory_threads_raw_coverage_e2e.rs @@ -152,7 +152,10 @@ use openhuman_core::openhuman::memory::rpc_models::{ }; use tinymemory_core::{ remember::RememberSourceKind, - rpc_models::{ApiEnvelope, ApiError, ApiMeta, PaginationMeta, QueryNamespaceRequest, RecallContextRequest, RecallMemoriesRequest}, + rpc_models::{ + ApiEnvelope, ApiError, ApiMeta, PaginationMeta, QueryNamespaceRequest, + RecallContextRequest, RecallMemoriesRequest, + }, traits::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts}, util::redact::{redact, redact_endpoint}, };