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}