aev-audio 0.1.0

Minimal Aev completion ping plugin.
Documentation
use std::ffi::c_char;
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;

pub const PLUGIN_ID: &str = "aev-audio";
pub const PLUGIN_NAME: &str = "aev-audio";
pub const CRATE_NAME: &str = "aev-audio";
pub const ENTRYPOINT: &str = "aev_plugin_init";
pub const PROTOCOL: &str = "aev.plugin.v1";
pub const PLUGIN_INIT_JSON: &str = r#"{"id":"aev-audio","name":"aev-audio","package":"aev-audio","entrypoint":"aev_plugin_init","protocol":"aev.plugin.v1","capabilities":["notification","audio","completion","journal-watch"]}"#;
const PLUGIN_INIT_JSON_NUL: &str = "{\"id\":\"aev-audio\",\"name\":\"aev-audio\",\"package\":\"aev-audio\",\"entrypoint\":\"aev_plugin_init\",\"protocol\":\"aev.plugin.v1\",\"capabilities\":[\"notification\",\"audio\",\"completion\",\"journal-watch\"]}\0";
const POLL_INTERVAL: Duration = Duration::from_millis(200);
const COMPLETION_EVENT_PREFIX: &str = "{\"event\":\"assistant_completed\"";

#[no_mangle]
pub extern "C" fn aev_plugin_init() -> *const c_char {
    PLUGIN_INIT_JSON_NUL.as_ptr().cast()
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginManifest {
    pub id: &'static str,
    pub name: &'static str,
    pub package: &'static str,
    pub entrypoint: &'static str,
    pub protocol: &'static str,
    pub capabilities: Vec<&'static str>,
}

pub fn plugin_manifest() -> PluginManifest {
    PluginManifest {
        id: PLUGIN_ID,
        name: PLUGIN_NAME,
        package: CRATE_NAME,
        entrypoint: ENTRYPOINT,
        protocol: PROTOCOL,
        capabilities: vec!["notification", "audio", "completion", "journal-watch"],
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WatchConfig {
    pub workspace: PathBuf,
    pub from_start: bool,
    pub once: bool,
}

impl WatchConfig {
    pub fn journal_path(&self) -> PathBuf {
        self.workspace.join(".aev").join("journal.jsonl")
    }
}

pub fn ping() -> io::Result<()> {
    write_ping(io::stderr())
}

pub fn watch(config: &WatchConfig) -> io::Result<()> {
    let journal_path = config.journal_path();
    let mut offset = if config.from_start {
        0
    } else {
        file_len(&journal_path).unwrap_or(0)
    };
    let mut pending = Vec::new();

    eprintln!("aev-audio watching {}", journal_path.display());
    loop {
        match read_appended(&journal_path, &mut offset) {
            Ok(bytes) => {
                let completions = completion_events(&mut pending, &bytes);
                for _ in 0..completions {
                    ping()?;
                }
                if config.once && completions > 0 {
                    return Ok(());
                }
            }
            Err(error) if error.kind() == io::ErrorKind::NotFound => {
                offset = 0;
                pending.clear();
            }
            Err(error) => return Err(error),
        }
        thread::sleep(POLL_INTERVAL);
    }
}

pub fn is_completion_event(line: &str) -> bool {
    line.trim_start().starts_with(COMPLETION_EVENT_PREFIX)
}

fn write_ping(mut writer: impl Write) -> io::Result<()> {
    writer.write_all(b"\x07")?;
    writer.flush()
}

fn file_len(path: &Path) -> io::Result<u64> {
    Ok(path.metadata()?.len())
}

fn read_appended(path: &Path, offset: &mut u64) -> io::Result<Vec<u8>> {
    let mut file = File::open(path)?;
    let len = file.metadata()?.len();
    if len < *offset {
        *offset = 0;
    }
    file.seek(SeekFrom::Start(*offset))?;
    let mut bytes = Vec::new();
    file.read_to_end(&mut bytes)?;
    *offset += bytes.len() as u64;
    Ok(bytes)
}

fn completion_events(pending: &mut Vec<u8>, bytes: &[u8]) -> usize {
    pending.extend_from_slice(bytes);
    let mut count = 0;
    while let Some(end) = pending.iter().position(|byte| *byte == b'\n') {
        let line = pending.drain(..=end).collect::<Vec<_>>();
        if is_completion_event(&String::from_utf8_lossy(&line)) {
            count += 1;
        }
    }
    count
}

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

    #[test]
    fn manifest_matches_aev_plugin_contract() {
        let manifest = plugin_manifest();
        assert_eq!(manifest.id, "aev-audio");
        assert_eq!(manifest.package, "aev-audio");
        assert_eq!(manifest.entrypoint, "aev_plugin_init");
        assert_eq!(manifest.protocol, "aev.plugin.v1");
        assert!(manifest.capabilities.contains(&"audio"));
        assert!(manifest.capabilities.contains(&"completion"));

        let json = unsafe { CStr::from_ptr(aev_plugin_init()) }
            .to_str()
            .expect("plugin manifest is utf-8");
        assert_eq!(json, PLUGIN_INIT_JSON);
    }

    #[test]
    fn completion_event_detection_is_narrow() {
        assert!(is_completion_event(
            r#"{"event":"assistant_completed","detail":"model response completed"}"#
        ));
        assert!(!is_completion_event(
            r#"{"event":"assistant_failed","detail":"assistant_completed"}"#
        ));
        assert!(!is_completion_event("not json"));
    }

    #[test]
    fn split_journal_chunks_emit_each_completed_line_once() {
        let mut pending = Vec::new();
        assert_eq!(
            completion_events(&mut pending, b"{\"event\":\"assistant_comp"),
            0
        );
        assert_eq!(
            completion_events(
                &mut pending,
                b"leted\",\"detail\":\"done\"}\n{\"event\":\"assistant_failed\"}\n"
            ),
            1
        );
        assert!(pending.is_empty());
    }

    #[test]
    fn ping_is_one_terminal_bell() {
        let mut output = Vec::new();
        write_ping(&mut output).expect("ping writes");
        assert_eq!(output, b"\x07");
    }
}