Skip to main content

hematite/agent/
lms.rs

1use std::io;
2use std::path::PathBuf;
3use std::process::{Command, Stdio};
4
5/// LM Studio CLI Harness for automated lifecycle management.
6/// Ports the "LMS Mastery" patterns from Codex-RS to ensure
7/// Hematite can auto-start and auto-load models.
8pub struct LmsHarness {
9    pub binary_path: Option<PathBuf>,
10}
11
12impl Default for LmsHarness {
13    fn default() -> Self {
14        Self::new()
15    }
16}
17
18impl LmsHarness {
19    pub fn new() -> Self {
20        Self {
21            binary_path: Self::find_lms(),
22        }
23    }
24
25    /// Locate the 'lms' binary in PATH or standard installation directories.
26    fn find_lms() -> Option<PathBuf> {
27        // 1. Try PATH via which
28        if let Ok(path) = which::which("lms") {
29            return Some(path);
30        }
31
32        // 2. Platform-specific fallbacks
33        let home = if cfg!(windows) {
34            std::env::var("USERPROFILE").ok()
35        } else {
36            std::env::var("HOME").ok()
37        };
38
39        if let Some(h) = home {
40            let bin_name = if cfg!(windows) { "lms.exe" } else { "lms" };
41            let fallback = PathBuf::from(h)
42                .join(".lmstudio")
43                .join("bin")
44                .join(bin_name);
45            if fallback.exists() {
46                return Some(fallback);
47            }
48        }
49
50        None
51    }
52
53    /// Check if the LM Studio server is responding on the expected port.
54    pub async fn is_server_responding(&self, base_url: &str) -> bool {
55        let client = reqwest::Client::builder()
56            .timeout(std::time::Duration::from_millis(1000))
57            .build()
58            .unwrap_or_default();
59
60        let url = format!("{}/models", base_url.trim_end_matches('/'));
61        match client.get(&url).send().await {
62            Ok(resp) => resp.status().is_success(),
63            Err(_) => false,
64        }
65    }
66
67    /// Attempt to start the LM Studio server if it's not responding.
68    pub fn ensure_server_running(&self) -> io::Result<()> {
69        let Some(ref lms) = self.binary_path else {
70            return Err(io::Error::new(io::ErrorKind::NotFound, "lms CLI not found"));
71        };
72
73        // We run this detached/background-ish so it doesn't block Hematite startup.
74        // LM Studio 'server start' is idempotent.
75        let status = Command::new(lms)
76            .args(["server", "start"])
77            .stdout(Stdio::null())
78            .stderr(Stdio::null())
79            .status()?;
80
81        if !status.success() {
82            return Err(io::Error::other("Failed to start lms server"));
83        }
84
85        Ok(())
86    }
87
88    /// Get a list of models currently known to LM Studio.
89    pub fn list_models(&self) -> io::Result<Vec<String>> {
90        let Some(ref lms) = self.binary_path else {
91            return Err(io::Error::new(io::ErrorKind::NotFound, "lms CLI not found"));
92        };
93
94        let output = Command::new(lms).args(["ls"]).output()?;
95
96        if !output.status.success() {
97            return Err(io::Error::other("Failed to list models via lms"));
98        }
99
100        let out_str = String::from_utf8_lossy(&output.stdout);
101        let models = out_str
102            .lines()
103            .filter(|l| !l.is_empty() && !l.starts_with("NAME")) // Skip header
104            .filter_map(|l| l.split_whitespace().next())
105            .map(|s| s.to_string())
106            .collect();
107
108        Ok(models)
109    }
110
111    /// Get a list of models currently loaded in memory.
112    pub fn list_loaded_models(&self) -> io::Result<Vec<String>> {
113        let Some(ref lms) = self.binary_path else {
114            return Err(io::Error::new(io::ErrorKind::NotFound, "lms CLI not found"));
115        };
116
117        let output = Command::new(lms).args(["ps"]).output()?;
118
119        if !output.status.success() {
120            return Err(io::Error::other("Failed to list loaded models via lms"));
121        }
122
123        let out_str = String::from_utf8_lossy(&output.stdout);
124        let models = out_str
125            .lines()
126            .filter(|line| !line.is_empty() && !line.starts_with("NAME"))
127            .filter_map(|line| line.split_whitespace().next())
128            .map(|value| value.to_string())
129            .collect();
130
131        Ok(models)
132    }
133
134    /// Load a specific model into the server.
135    pub fn load_model(&self, model_id: &str) -> io::Result<()> {
136        let Some(ref lms) = self.binary_path else {
137            return Err(io::Error::new(io::ErrorKind::NotFound, "lms CLI not found"));
138        };
139
140        let status = Command::new(lms)
141            .args(["load", model_id])
142            .stdout(Stdio::null())
143            .stderr(Stdio::null())
144            .status()?;
145
146        if !status.success() {
147            return Err(io::Error::other(format!(
148                "Failed to load model: {}",
149                model_id
150            )));
151        }
152
153        Ok(())
154    }
155
156    /// Unload a specific model from the server.
157    pub fn unload_model(&self, model_id: &str) -> io::Result<()> {
158        let Some(ref lms) = self.binary_path else {
159            return Err(io::Error::new(io::ErrorKind::NotFound, "lms CLI not found"));
160        };
161
162        let status = Command::new(lms)
163            .args(["unload", model_id])
164            .stdout(Stdio::null())
165            .stderr(Stdio::null())
166            .status()?;
167
168        if !status.success() {
169            return Err(io::Error::other(format!(
170                "Failed to unload model: {}",
171                model_id
172            )));
173        }
174
175        Ok(())
176    }
177
178    /// Unload all loaded models from the server.
179    pub fn unload_all_models(&self) -> io::Result<()> {
180        let Some(ref lms) = self.binary_path else {
181            return Err(io::Error::new(io::ErrorKind::NotFound, "lms CLI not found"));
182        };
183
184        let status = Command::new(lms)
185            .args(["unload", "--all"])
186            .stdout(Stdio::null())
187            .stderr(Stdio::null())
188            .status()?;
189
190        if !status.success() {
191            return Err(io::Error::other("Failed to unload all models"));
192        }
193
194        Ok(())
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_lms_discovery() {
204        let harness = LmsHarness::new();
205        // We can't guarantee 'lms' is on the test machine, but we can verify the fallback path logic.
206        if let Some(path) = harness.binary_path {
207            assert!(path.exists());
208        }
209    }
210}