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}