Skip to main content

aev_audio/
lib.rs

1use std::ffi::c_char;
2use std::fs::File;
3use std::io::{self, Read, Seek, SeekFrom, Write};
4use std::path::{Path, PathBuf};
5use std::thread;
6use std::time::Duration;
7
8pub const PLUGIN_ID: &str = "aev-audio";
9pub const PLUGIN_NAME: &str = "aev-audio";
10pub const CRATE_NAME: &str = "aev-audio";
11pub const ENTRYPOINT: &str = "aev_plugin_init";
12pub const PROTOCOL: &str = "aev.plugin.v1";
13pub 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"]}"#;
14const 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";
15const POLL_INTERVAL: Duration = Duration::from_millis(200);
16const COMPLETION_EVENT_PREFIX: &str = "{\"event\":\"assistant_completed\"";
17
18#[no_mangle]
19pub extern "C" fn aev_plugin_init() -> *const c_char {
20    PLUGIN_INIT_JSON_NUL.as_ptr().cast()
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct PluginManifest {
25    pub id: &'static str,
26    pub name: &'static str,
27    pub package: &'static str,
28    pub entrypoint: &'static str,
29    pub protocol: &'static str,
30    pub capabilities: Vec<&'static str>,
31}
32
33pub fn plugin_manifest() -> PluginManifest {
34    PluginManifest {
35        id: PLUGIN_ID,
36        name: PLUGIN_NAME,
37        package: CRATE_NAME,
38        entrypoint: ENTRYPOINT,
39        protocol: PROTOCOL,
40        capabilities: vec!["notification", "audio", "completion", "journal-watch"],
41    }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct WatchConfig {
46    pub workspace: PathBuf,
47    pub from_start: bool,
48    pub once: bool,
49}
50
51impl WatchConfig {
52    pub fn journal_path(&self) -> PathBuf {
53        self.workspace.join(".aev").join("journal.jsonl")
54    }
55}
56
57pub fn ping() -> io::Result<()> {
58    write_ping(io::stderr())
59}
60
61pub fn watch(config: &WatchConfig) -> io::Result<()> {
62    let journal_path = config.journal_path();
63    let mut offset = if config.from_start {
64        0
65    } else {
66        file_len(&journal_path).unwrap_or(0)
67    };
68    let mut pending = Vec::new();
69
70    eprintln!("aev-audio watching {}", journal_path.display());
71    loop {
72        match read_appended(&journal_path, &mut offset) {
73            Ok(bytes) => {
74                let completions = completion_events(&mut pending, &bytes);
75                for _ in 0..completions {
76                    ping()?;
77                }
78                if config.once && completions > 0 {
79                    return Ok(());
80                }
81            }
82            Err(error) if error.kind() == io::ErrorKind::NotFound => {
83                offset = 0;
84                pending.clear();
85            }
86            Err(error) => return Err(error),
87        }
88        thread::sleep(POLL_INTERVAL);
89    }
90}
91
92pub fn is_completion_event(line: &str) -> bool {
93    line.trim_start().starts_with(COMPLETION_EVENT_PREFIX)
94}
95
96fn write_ping(mut writer: impl Write) -> io::Result<()> {
97    writer.write_all(b"\x07")?;
98    writer.flush()
99}
100
101fn file_len(path: &Path) -> io::Result<u64> {
102    Ok(path.metadata()?.len())
103}
104
105fn read_appended(path: &Path, offset: &mut u64) -> io::Result<Vec<u8>> {
106    let mut file = File::open(path)?;
107    let len = file.metadata()?.len();
108    if len < *offset {
109        *offset = 0;
110    }
111    file.seek(SeekFrom::Start(*offset))?;
112    let mut bytes = Vec::new();
113    file.read_to_end(&mut bytes)?;
114    *offset += bytes.len() as u64;
115    Ok(bytes)
116}
117
118fn completion_events(pending: &mut Vec<u8>, bytes: &[u8]) -> usize {
119    pending.extend_from_slice(bytes);
120    let mut count = 0;
121    while let Some(end) = pending.iter().position(|byte| *byte == b'\n') {
122        let line = pending.drain(..=end).collect::<Vec<_>>();
123        if is_completion_event(&String::from_utf8_lossy(&line)) {
124            count += 1;
125        }
126    }
127    count
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use std::ffi::CStr;
134
135    #[test]
136    fn manifest_matches_aev_plugin_contract() {
137        let manifest = plugin_manifest();
138        assert_eq!(manifest.id, "aev-audio");
139        assert_eq!(manifest.package, "aev-audio");
140        assert_eq!(manifest.entrypoint, "aev_plugin_init");
141        assert_eq!(manifest.protocol, "aev.plugin.v1");
142        assert!(manifest.capabilities.contains(&"audio"));
143        assert!(manifest.capabilities.contains(&"completion"));
144
145        let json = unsafe { CStr::from_ptr(aev_plugin_init()) }
146            .to_str()
147            .expect("plugin manifest is utf-8");
148        assert_eq!(json, PLUGIN_INIT_JSON);
149    }
150
151    #[test]
152    fn completion_event_detection_is_narrow() {
153        assert!(is_completion_event(
154            r#"{"event":"assistant_completed","detail":"model response completed"}"#
155        ));
156        assert!(!is_completion_event(
157            r#"{"event":"assistant_failed","detail":"assistant_completed"}"#
158        ));
159        assert!(!is_completion_event("not json"));
160    }
161
162    #[test]
163    fn split_journal_chunks_emit_each_completed_line_once() {
164        let mut pending = Vec::new();
165        assert_eq!(
166            completion_events(&mut pending, b"{\"event\":\"assistant_comp"),
167            0
168        );
169        assert_eq!(
170            completion_events(
171                &mut pending,
172                b"leted\",\"detail\":\"done\"}\n{\"event\":\"assistant_failed\"}\n"
173            ),
174            1
175        );
176        assert!(pending.is_empty());
177    }
178
179    #[test]
180    fn ping_is_one_terminal_bell() {
181        let mut output = Vec::new();
182        write_ping(&mut output).expect("ping writes");
183        assert_eq!(output, b"\x07");
184    }
185}