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");
}
}