faculties 0.21.0

An office suite for AI agents: kanban, wiki, files, messaging, and a Lissajous-backed viewer — all persisted in a TribleSpace pile.
//! Explicit MCP UX: a speech clip is an audio attachment, never an instruction
//! to play the server's speakers. Routing metadata is a separate operation.
use super::synthesis::{ModelSources, Synthesizer};
use super::{Channel, Voice as Operations};
use crate::mcp::{decode_arguments, invalid_arguments, Faculty, Tool};
use crate::out::Out;
use anybytes::Bytes;
use anyhow::{bail, Result};
use serde::Deserialize;
use std::path::PathBuf;

const TOOLS: &[Tool] = &[
    Tool { name: "voice_synthesize", description: "Synthesize literal text with the launcher's local Qwen3-TTS model and return a WAV audio attachment. Never enumerates or plays local audio devices, changes the floor, or records a spoken utterance: this is generation, not proof anyone heard it. Requires the voice build feature and configured model/reference assets; discovery opens none.", input_schema: r#"{"type":"object","properties":{"text":{"type":"string","minLength":1}},"required":["text"],"additionalProperties":false}"# },
    Tool { name: "voice_route", description: "Read the latest stored private say and public shout routing policies (or built-in defaults). Metadata only: no device enumeration, Reachy probe, synthesis, or playback. Private say routing still rejects public devices even if its policy lists them.", input_schema: r#"{"type":"object","properties":{},"additionalProperties":false}"# },
    Tool { name: "voice_route_set", description: "Publish an ordered device-name preference list for local say or shout routing. Names are literal case-insensitive match patterns, not files. A new timestamped generation replaces older policy generations; exact timestamp ties are unioned. This changes future host routing only and does not play audio. The say privacy check cannot be configured away.", input_schema: r#"{"type":"object","properties":{"channel":{"type":"string","enum":["say","shout"]},"devices":{"type":"array","minItems":1,"items":{"type":"string","minLength":1}}},"required":["channel","devices"],"additionalProperties":false}"# },
];
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Text {
    text: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RouteSet {
    channel: Channel,
    devices: Vec<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Empty {}
pub struct Voice {
    operations: Operations,
    synthesizer: Synthesizer,
}
impl Voice {
    pub fn new(pile: PathBuf, key: Option<PathBuf>) -> Self {
        Self::with_storage(crate::storage::Storage::new(pile, key))
    }
    pub fn with_storage(storage: crate::storage::Storage) -> Self {
        Self::with_storage_and_sources(storage, ModelSources::from_environment())
    }
    pub fn with_sources(pile: PathBuf, key: Option<PathBuf>, sources: ModelSources) -> Self {
        Self::with_storage_and_sources(crate::storage::Storage::new(pile, key), sources)
    }
    pub fn with_storage_and_sources(
        storage: crate::storage::Storage,
        sources: ModelSources,
    ) -> Self {
        Self {
            operations: Operations::with_storage(storage),
            synthesizer: Synthesizer::new(sources),
        }
    }
}
impl Faculty for Voice {
    fn tools(&self) -> &[Tool] {
        TOOLS
    }
    fn call(&self, name: &str, arguments: Bytes, out: &mut Out<'_>) -> Result<()> {
        match name {
            "voice_synthesize" => {
                let args: Text = decode_arguments(arguments)?;
                super::operations::validate_text(&args.text).map_err(invalid_arguments)?;
                let clip = self.synthesizer.synthesize(&args.text)?;
                out.audio(clip.wav.clone(), super::AUDIO_WAV_MIME)?;
                out.line(format!(
                    "{} Hz mono, {} samples ({:.2}s); no host playback",
                    clip.sample_rate,
                    clip.sample_count,
                    clip.duration_seconds()
                ))
            }
            "voice_route" => {
                let _: Empty = decode_arguments(arguments)?;
                for policy in self.operations.routes()? {
                    out.line(format!(
                        "{} policy (priority order): {}",
                        policy.channel.name(),
                        policy.devices.join(" → ")
                    ))?;
                }
                Ok(())
            }
            "voice_route_set" => {
                let args: RouteSet = decode_arguments(arguments)?;
                super::operations::validate_devices(&args.devices).map_err(invalid_arguments)?;
                self.operations
                    .set_route(args.channel, &args.devices)?
                    .emit(out)
            }
            _ => bail!("Voice MCP has no tool {name:?}"),
        }
    }
}