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.
//! Finite recorded-audio processing only; discovery never opens a model,
//! device, local side file, output directory or capture stream.
use super::{ModelConfig, Options, DEFAULT_PROMPT};
use crate::mcp::{decode_arguments, invalid_arguments, Faculty, Tool};
use crate::out::Out;
use crate::turntaking::SpeechFilter;
use anybytes::Bytes;
use anyhow::{bail, Result};
use base64::Engine as _;
use serde::Deserialize;

pub struct Hear {
    config: Option<ModelConfig>,
}
impl Hear {
    pub fn new(config: Option<ModelConfig>) -> Self {
        Self { config }
    }
}
const TOOLS:&[Tool]=&[Tool{
    name:"hear_once",
    description:"Run a resident encoded audio clip through the shared VAD, speech filters and launcher-configured Gemma hearing stack. Returns ordered metadata and exact raw f32le row-major embedding resources, not audio playback; optional text is diagnostic. Requires the hear build feature and explicit local model/config/tokenizer configuration. No microphone, Soma listener, host input file, download, log path or model selection can be requested.",
    input_schema:r#"{"type":"object","properties":{"data":{"type":"string","contentEncoding":"base64"},"mime_type":{"type":"string","default":"audio/wav"},"source":{"type":"string","default":"resident"},"transcribe":{"type":"boolean","default":false},"prompt":{"type":"string","default":"Transcribe exactly what is being said."},"tokens":{"type":"integer","minimum":1,"default":64},"min_chars":{"type":"integer","minimum":0,"default":2},"min_dur_s":{"type":"number","minimum":0,"default":0.6}},"required":["data"],"additionalProperties":false}"#
}];
fn mime() -> String {
    "audio/wav".to_owned()
}
fn source() -> String {
    "resident".to_owned()
}
fn prompt() -> String {
    DEFAULT_PROMPT.to_owned()
}
fn tokens() -> usize {
    64
}
fn min_chars() -> usize {
    2
}
fn min_dur() -> f64 {
    0.6
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Once {
    data: String,
    #[serde(default = "mime")]
    mime_type: String,
    #[serde(default = "source")]
    source: String,
    #[serde(default)]
    transcribe: bool,
    #[serde(default = "prompt")]
    prompt: String,
    #[serde(default = "tokens")]
    tokens: usize,
    #[serde(default = "min_chars")]
    min_chars: usize,
    #[serde(default = "min_dur")]
    min_dur_s: f64,
}
impl Faculty for Hear {
    fn tools(&self) -> &[Tool] {
        TOOLS
    }
    fn call(&self, name: &str, arguments: Bytes, out: &mut Out<'_>) -> Result<()> {
        if name != "hear_once" {
            bail!("unknown Hear MCP tool {name:?}");
        }
        let a: Once = decode_arguments(arguments)?;
        super::operations::mime_hint(&a.mime_type).map_err(invalid_arguments)?;
        if a.tokens == 0 {
            return Err(invalid_arguments(
                "transcription token limit must be positive",
            ));
        }
        let options = Options {
            transcribe: a.transcribe,
            prompt: a.prompt,
            tokens: a.tokens,
            filter: SpeechFilter {
                min_chars: a.min_chars,
                min_dur_s: a.min_dur_s,
                ..Default::default()
            },
        };
        options.validate().map_err(invalid_arguments)?;
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(&a.data)
            .map_err(invalid_arguments)?;
        if bytes.is_empty() {
            return Err(invalid_arguments("recorded audio contains no bytes"));
        }
        let config=self.config.as_ref().ok_or_else(||anyhow::anyhow!("Hear has no launcher-configured local model pile/config/tokenizer; no model or audio device was opened"))?;
        let summary = super::Hear::new(config.clone()).once(
            bytes.into(),
            &a.mime_type,
            &a.source,
            &options,
            out,
        )?;
        out.line(format!(
            "{} utterance(s); {} embedded, {} dropped",
            summary.segments, summary.embedded, summary.dropped
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn mcp_prompt_and_source_are_literal() {
        let args: Once = decode_arguments(Bytes::from(
            r#"{"data":"AQ==","prompt":"@/not-a-file","source":"@-"}"#,
        ))
        .unwrap();
        assert_eq!(args.prompt, "@/not-a-file");
        assert_eq!(args.source, "@-");
        assert_eq!(args.mime_type, "audio/wav");
        assert!(!args.transcribe);
    }
}