1//! How much memory a new program could use right now, for the headroom gate. 2//! 3//! The built-in measure is the operating system's own, asked for directly 4//! through the `sysinfo` crate (no subprocess, nothing for the user to 5//! install): total memory less what is in use. On Linux that is 6//! `MemAvailable`; on Windows, `ullAvailPhys` from `GlobalMemoryStatusEx`; on 7//! macOS, total less app, wired and compressed memory from 8//! `host_statistics64`, which is what Activity Monitor shows as cached files 9//! plus free. It is NOT `sysinfo`'s own `available_memory()`: on macOS that 10//! counts active pages (sysinfo 0.39.6, `src/unix/apple/system.rs`), which 11//! are programs' memory in use, and would report a busy machine as nearly 12//! all available. That is the whole machine's truth on bare metal, but not 13//! everywhere: a virtual machine sees its own memory, not its host's. So the 14//! measure can be replaced by a command in the config file, and on a system 15//! `sysinfo` does not know, with no command, the load of a command is simply 16//! not judged. Nothing here assumes a particular host. 17 18use std::path::PathBuf; 19use std::sync::{Mutex, PoisonError}; 20use std::time::{Duration, Instant}; 21 22use jevhooks_events::Tier; 23use serde::Deserialize; 24use serde_json::{Value, json}; 25 26/// A measurement is reused for this long: commands arrive in bursts, and a 27/// configured command may cost more than reading a file. 28const FRESH_FOR: Duration = Duration::from_secs(5); 29 30/// How long a configured command may take before it counts as failed. 31const COMMAND_WITHIN: Duration = Duration::from_secs(2); 32 33/// `$XDG_CONFIG_HOME/jevhooks/config.toml`, or `~/.config/jevhooks/config.toml`. 34pub fn config_path() -> PathBuf { 35 let base = std::env::var_os("XDG_CONFIG_HOME") 36 .filter(|v| !v.is_empty()) 37 .map(PathBuf::from) 38 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) 39 .unwrap_or_else(|| PathBuf::from(".")); 40 base.join("jevhooks").join("config.toml") 41} 42 43/// The daemon's config file. Every key is optional; an unknown key is an 44/// error, so a misspelt setting is reported instead of ignored. 45#[derive(Debug, Default, Deserialize)] 46#[serde(deny_unknown_fields)] 47pub struct Config { 48 /// A shell command that prints, as its first number, the megabytes of 49 /// memory a new program could use. Replaces the built-in measure. 50 pub available_memory_command: Option<String>, 51 /// A command that runs its arguments with Jev's key in their environment 52 /// (`fnox exec --`, `op run --`, a wrapper of the user's own). The daemon 53 /// is started through it when the key is not already in the environment 54 /// of the session that starts it. Split on whitespace; no shell. 55 pub secrets_command: Option<String>, 56 /// Whether Jev picks the model each prompt's turn is given to. Off unless 57 /// set: the model is the user's choice until they hand it over. 58 #[serde(default)] 59 pub choose_model: bool, 60 /// Which model each tier means here, for a setup whose model ids differ 61 /// (a cloud provider's, a gateway's). A tier left out keeps its default. 62 #[serde(default)] 63 pub models: Models, 64} 65 66/// The config file's `[models]` table: a model id per tier. 67#[derive(Debug, Default, Deserialize)] 68#[serde(deny_unknown_fields)] 69pub struct Models { 70 pub haiku: Option<String>, 71 pub sonnet: Option<String>, 72 pub opus: Option<String>, 73 pub fable: Option<String>, 74} 75 76impl Models { 77 /// The model id `tier` means: the configured one, else Anthropic's own 78 /// id for the current model of that size. 79 pub fn id(&self, tier: Tier) -> &str { 80 let (configured, default) = match tier { 81 Tier::Haiku => (&self.haiku, "claude-haiku-4-5-20251001"), 82 Tier::Sonnet => (&self.sonnet, "claude-sonnet-5-5"), 83 Tier::Opus => (&self.opus, "claude-opus-5-5"), 84 Tier::Fable => (&self.fable, "claude-fable-5-1"), 85 }; 86 configured.as_deref().unwrap_or(default) 87 } 88} 89 90impl Config { 91 /// The config file's settings; the defaults when there is no file; an 92 /// error when there is one that cannot be read or understood. 93 pub fn load() -> Result<Self, String> { 94 let path = config_path(); 95 match std::fs::read_to_string(&path) { 96 Ok(text) => toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display())), 97 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), 98 Err(e) => Err(format!("{}: {e}", path.display())), 99 } 100 } 101} 102 103/// Where the available-memory figure comes from. Exactly one source: a 104/// configured command is never silently replaced by the built-in measure, 105/// because the user configured it to say the built-in one is wrong here. 106enum Source { 107 Command(String), 108 /// What the operating system reports as available. 109 System, 110 /// A system `sysinfo` does not support, and no command configured. 111 None, 112} 113 114/// One measurement: megabytes, or why there is no figure. 115type Measured = Result<f64, String>; 116 117pub struct Headroom { 118 source: Source, 119 last: Mutex<Option<(Instant, Measured)>>, 120} 121 122/// The first number in `text`, as megabytes. 123fn first_number(text: &str) -> Option<f64> { 124 text.split(|c: char| !(c.is_ascii_digit() || c == '.')).find(|part| !part.is_empty())?.parse().ok() 125} 126 127fn system_mb() -> Measured { 128 let mut system = sysinfo::System::new(); 129 system.refresh_memory(); 130 // A total of zero is `sysinfo` saying it could not read the figures. 131 match (system.total_memory(), system.used_memory()) { 132 (0, _) => Err("the operating system reported no memory figures".to_owned()), 133 (total, used) => Ok(total.saturating_sub(used) as f64 / (1024.0 * 1024.0)), 134 } 135} 136 137async fn command_mb(command: &str) -> Measured { 138 let run = tokio::process::Command::new("sh").arg("-c").arg(command).kill_on_drop(true).output(); 139 let output = tokio::time::timeout(COMMAND_WITHIN, run) 140 .await 141 .map_err(|_| format!("available_memory_command did not finish within {} s", COMMAND_WITHIN.as_secs()))? 142 .map_err(|e| format!("available_memory_command could not run: {e}"))?; 143 if !output.status.success() { 144 return Err(format!("available_memory_command exited with {}", output.status)); 145 } 146 first_number(&String::from_utf8_lossy(&output.stdout)) 147 .ok_or_else(|| "available_memory_command printed no number".to_owned()) 148} 149 150impl Headroom { 151 pub fn new(config: &Config) -> Self { 152 let source = match &config.available_memory_command { 153 Some(command) => Source::Command(command.clone()), 154 None if sysinfo::IS_SUPPORTED_SYSTEM => Source::System, 155 None => Source::None, 156 }; 157 Self { source, last: Mutex::new(None) } 158 } 159 160 async fn measure(&self) -> Measured { 161 let fresh = self.last.lock().unwrap_or_else(PoisonError::into_inner).clone(); 162 if let Some((at, measured)) = fresh 163 && at.elapsed() < FRESH_FOR 164 { 165 return measured; 166 } 167 let measured = match &self.source { 168 Source::Command(command) => command_mb(command).await, 169 Source::System => system_mb(), 170 Source::None => Err("no built-in measure on this system; set available_memory_command".to_owned()), 171 }; 172 *self.last.lock().unwrap_or_else(PoisonError::into_inner) = Some((Instant::now(), measured.clone())); 173 measured 174 } 175 176 /// Megabytes available now, or nothing when it cannot be measured, in 177 /// which case a command's load is not judged. 178 pub async fn available_mb(&self) -> Option<f64> { 179 self.measure().await.ok() 180 } 181 182 /// For `status`: where the figure comes from and what it is now. 183 pub async fn status(&self) -> Value { 184 let source = match &self.source { 185 Source::Command(command) => format!("command: {command}"), 186 Source::System => "the operating system's available memory".to_owned(), 187 Source::None => "none".to_owned(), 188 }; 189 match self.measure().await { 190 Ok(available) => json!({ "source": source, "available_mb": available.round() }), 191 Err(why) => json!({ "source": source, "error": why }), 192 } 193 } 194} 195 196#[cfg(test)] 197mod tests { 198 use super::*; 199 200 /// A user's memory command may print units or a label; only its first number is the figure. 201 #[test] 202 fn the_first_number_is_read_whatever_surrounds_it() { 203 assert_eq!(first_number("8123\n"), Some(8123.0)); 204 assert_eq!(first_number("available: 5120.5 MB of 16384"), Some(5120.5)); 205 assert_eq!(first_number("none"), None); 206 assert_eq!(first_number(""), None); 207 } 208 209 /// `[models]` renames one size without the others, and a size that does not exist is a typo, 210 /// not a setting. 211 #[test] 212 fn a_tier_means_its_configured_model_or_the_default() { 213 let config: Config = toml::from_str("[models]\nopus = \"my-gateway/opus\"\n").unwrap(); 214 assert_eq!(config.models.id(Tier::Opus), "my-gateway/opus"); 215 assert_eq!(config.models.id(Tier::Haiku), "claude-haiku-4-5-20251001"); 216 assert!(toml::from_str::<Config>("[models]\ngpt = \"x\"\n").is_err()); 217 } 218 219 /// A typo in the config must be reported, never ignored: an ignored key would switch a gate off 220 /// without a word. 221 #[test] 222 fn a_misspelt_setting_is_an_error() { 223 assert!(toml::from_str::<Config>("available_memory_comand = \"x\"").is_err()); 224 assert!(toml::from_str::<Config>("").unwrap().available_memory_command.is_none()); 225 } 226 227 /// The figure asked of the operating system is the kernel's own "available", not "free": cache 228 /// that can be given back counts as room. 229 #[test] 230 fn the_built_in_figure_agrees_with_the_kernel() { 231 // Where the kernel's own figure can be read directly, the built-in 232 // measure is that figure, to within what changes between two reads. 233 let Ok(meminfo) = std::fs::read_to_string("/proc/meminfo") else { return }; 234 let kernel = meminfo 235 .lines() 236 .find_map(|line| line.strip_prefix("MemAvailable:")) 237 .and_then(first_number) 238 .expect("MemAvailable") 239 / 1024.0; 240 let measured = system_mb().expect("a figure"); 241 assert!((measured - kernel).abs() < 512.0, "sysinfo {measured} MB, kernel {kernel} MB"); 242 } 243 244 /// A command that fails means "unknown". Falling back to the built-in figure would bring back 245 /// the number the user configured the command to replace. 246 #[tokio::test] 247 async fn a_configured_command_is_the_only_source() { 248 let config = Config { available_memory_command: Some("echo 1234".into()), ..Config::default() }; 249 assert_eq!(Headroom::new(&config).available_mb().await, Some(1234.0)); 250 // A failing command gives no figure; it does not fall back to the built-in measure. 251 let config = Config { available_memory_command: Some("exit 3".into()), ..Config::default() }; 252 assert_eq!(Headroom::new(&config).available_mb().await, None); 253 } 254}