Skip to main content

hematite/agent/
config.rs

1/// Hematite project-level configuration.
2///
3/// Read from `.hematite/settings.json` in the workspace root.
4/// Cached in-process with a 500 ms TTL; save_config() invalidates immediately.
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8pub const DEFAULT_LM_STUDIO_API_URL: &str = "http://localhost:1234/v1";
9pub const DEFAULT_OLLAMA_API_URL: &str = "http://localhost:11434/v1";
10
11fn default_true() -> bool {
12    true
13}
14
15#[derive(Serialize, Deserialize, Default, Clone, Copy, Debug, PartialEq)]
16pub enum PermissionMode {
17    #[default]
18    Developer,
19    ReadOnly,
20    SystemAdmin,
21}
22
23#[derive(Serialize, Deserialize, Clone, Debug)]
24pub struct HematiteConfig {
25    /// Active authority mode.
26    #[serde(default)]
27    pub mode: PermissionMode,
28    /// Pattern-based permission overrides.
29    pub permissions: Option<PermissionRules>,
30    /// Workspace trust policy for the current project root.
31    #[serde(default)]
32    pub trust: WorkspaceTrustConfig,
33    /// Override the primary model ID (e.g. "gemma-4-e4b").
34    pub model: Option<String>,
35    /// Override the fast model ID used for read-only tasks.
36    pub fast_model: Option<String>,
37    /// Override the think model ID used for complex tasks.
38    pub think_model: Option<String>,
39    /// Preferred embedding model to keep loaded for semantic search.
40    pub embed_model: Option<String>,
41    /// When true, Gemma 4 models enable native-formatting behavior automatically unless explicitly forced off.
42    #[serde(default = "default_true")]
43    pub gemma_native_auto: bool,
44    /// Force Gemma-native request shaping on for Gemma 4 models.
45    #[serde(default)]
46    pub gemma_native_formatting: bool,
47    /// Override the LLM provider base URL (e.g. "http://localhost:11434/v1" for Ollama).
48    /// Defaults to "http://localhost:1234/v1" (LM Studio). Takes precedence over --url CLI flag.
49    pub api_url: Option<String>,
50    /// Voice ID for TTS. Use /voice in the TUI to list and select. Defaults to "af_sky".
51    pub voice: Option<String>,
52    /// TTS speech speed multiplier. 1.0 = normal, 0.8 = slower, 1.3 = faster. Defaults to 1.0.
53    pub voice_speed: Option<f32>,
54    /// TTS volume. 0.0 = silent, 1.0 = normal, 2.0 = louder. Defaults to 1.0.
55    pub voice_volume: Option<f32>,
56    /// Extra text appended verbatim to the system prompt (project notes, conventions, etc.).
57    pub context_hint: Option<String>,
58    /// Override path to the Deno executable for the run_code sandbox.
59    /// If unset, Hematite checks LM Studio's bundled Deno, then system PATH.
60    /// Example: "C:/Users/you/.deno/bin/deno.exe"
61    pub deno_path: Option<String>,
62    pub python_path: Option<String>,
63    /// Per-project verification commands for build/test/lint/fix workflows.
64    #[serde(default)]
65    pub verify: VerifyProfilesConfig,
66    /// Tool Lifecycle Hooks for automated pre/post scripts.
67    #[serde(default)]
68    pub hooks: crate::agent::hooks::RuntimeHookConfig,
69    /// Optional local SearXNG URL (e.g. "http://localhost:8080") for private research.
70    /// If set, research_web will prioritize this endpoint over external search proxies.
71    pub searx_url: Option<String>,
72    /// When true, Hematite will attempt to automatically start SearXNG on startup if it's offline.
73    #[serde(default = "default_true")]
74    pub auto_start_searx: bool,
75    /// When true, Hematite stops a SearXNG stack on exit only if this session started it.
76    #[serde(default)]
77    pub auto_stop_searx: bool,
78}
79
80impl Default for HematiteConfig {
81    fn default() -> Self {
82        Self {
83            mode: PermissionMode::Developer,
84            permissions: None,
85            trust: WorkspaceTrustConfig::default(),
86            model: None,
87            fast_model: None,
88            think_model: None,
89            embed_model: None,
90            gemma_native_auto: true,
91            gemma_native_formatting: false,
92            api_url: None,
93            voice: None,
94            voice_speed: None,
95            voice_volume: None,
96            context_hint: None,
97            deno_path: None,
98            python_path: None,
99            verify: VerifyProfilesConfig::default(),
100            hooks: crate::agent::hooks::RuntimeHookConfig::default(),
101            searx_url: None,
102            auto_start_searx: true,
103            auto_stop_searx: false,
104        }
105    }
106}
107
108#[derive(Serialize, Deserialize, Clone, Debug)]
109pub struct WorkspaceTrustConfig {
110    /// Workspace roots trusted for normal destructive and external tool posture.
111    #[serde(default = "default_trusted_workspace_roots")]
112    pub allow: Vec<String>,
113    /// Workspace roots explicitly denied for destructive and external tool posture.
114    #[serde(default)]
115    pub deny: Vec<String>,
116}
117
118impl Default for WorkspaceTrustConfig {
119    fn default() -> Self {
120        Self {
121            allow: default_trusted_workspace_roots(),
122            deny: Vec::new(),
123        }
124    }
125}
126
127fn default_trusted_workspace_roots() -> Vec<String> {
128    vec![".".to_string()]
129}
130
131#[derive(Serialize, Deserialize, Default, Clone, Debug)]
132pub struct VerifyProfilesConfig {
133    /// Optional default profile name to use when verify_build is called without an explicit profile.
134    pub default_profile: Option<String>,
135    /// Named verification profiles keyed by stack or workspace role.
136    #[serde(default)]
137    pub profiles: BTreeMap<String, VerifyProfile>,
138}
139
140#[derive(Serialize, Deserialize, Default, Clone, Debug)]
141pub struct VerifyProfile {
142    /// Build/compile validation command.
143    pub build: Option<String>,
144    /// Test command.
145    pub test: Option<String>,
146    /// Lint/static analysis command.
147    pub lint: Option<String>,
148    /// Optional auto-fix command, typically lint --fix or formatter repair.
149    pub fix: Option<String>,
150    /// Optional timeout override for this profile.
151    pub timeout_secs: Option<u64>,
152}
153
154#[derive(Serialize, Deserialize, Default, Clone, Debug)]
155pub struct PermissionRules {
156    /// Always auto-approve these patterns (e.g. "cargo *", "git status").
157    #[serde(default)]
158    pub allow: Vec<String>,
159    /// Always require approval for these patterns (e.g. "git push *").
160    #[serde(default)]
161    pub ask: Vec<String>,
162    /// Always deny these patterns outright (e.g. "rm -rf *").
163    #[serde(default)]
164    pub deny: Vec<String>,
165}
166
167pub fn settings_path() -> std::path::PathBuf {
168    crate::tools::file_ops::hematite_dir().join("settings.json")
169}
170
171/// Load global settings from `~/.hematite/settings.json` if present.
172fn load_global_config() -> Option<HematiteConfig> {
173    let home = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME"))?;
174    let path = std::path::PathBuf::from(home)
175        .join(".hematite")
176        .join("settings.json");
177    let data = std::fs::read_to_string(&path).ok()?;
178    serde_json::from_str(&data).ok()
179}
180
181static CONFIG_CACHE: std::sync::Mutex<Option<(std::time::Instant, HematiteConfig)>> =
182    std::sync::Mutex::new(None);
183
184const CONFIG_TTL: std::time::Duration = std::time::Duration::from_millis(500);
185
186/// Invalidate the in-process config cache. Called by save_config() so that the
187/// next load_config() sees the freshly written file immediately.
188pub fn invalidate_config_cache() {
189    if let Ok(mut g) = CONFIG_CACHE.lock() {
190        *g = None;
191    }
192}
193
194/// Load `.hematite/settings.json` from the workspace root, with global
195/// `~/.hematite/settings.json` as a fallback for unset fields.
196/// Workspace config always wins; global fills in what workspace doesn't set.
197/// Results are cached for 500 ms so multiple per-turn call sites share one read.
198pub fn load_config() -> HematiteConfig {
199    // Fast path: return the cached config if it is still fresh.
200    if let Ok(g) = CONFIG_CACHE.lock() {
201        if let Some((t, ref cfg)) = *g {
202            if t.elapsed() < CONFIG_TTL {
203                return cfg.clone();
204            }
205        }
206    }
207
208    let cfg = load_config_uncached();
209
210    if let Ok(mut g) = CONFIG_CACHE.lock() {
211        *g = Some((std::time::Instant::now(), cfg.clone()));
212    }
213    cfg
214}
215
216fn load_config_uncached() -> HematiteConfig {
217    let path = settings_path();
218
219    let workspace: Option<HematiteConfig> = if path.exists() {
220        let content = std::fs::read_to_string(&path).ok();
221        if let Some(d) = content {
222            serde_json::from_str(&d).ok()
223        } else {
224            None
225        }
226    } else {
227        write_default_config(&path);
228        None
229    };
230
231    let global = load_global_config();
232
233    match (workspace, global) {
234        (Some(ws), Some(gb)) => {
235            // Workspace wins on every field that isn't the zero/null default
236            HematiteConfig {
237                model: ws.model.or(gb.model),
238                fast_model: ws.fast_model.or(gb.fast_model),
239                think_model: ws.think_model.or(gb.think_model),
240                embed_model: ws.embed_model.or(gb.embed_model),
241                api_url: ws.api_url.or(gb.api_url),
242                voice: if ws.voice != HematiteConfig::default().voice {
243                    ws.voice
244                } else {
245                    gb.voice
246                },
247                voice_speed: ws.voice_speed.or(gb.voice_speed),
248                voice_volume: ws.voice_volume.or(gb.voice_volume),
249                context_hint: ws.context_hint.or(gb.context_hint),
250                deno_path: ws.deno_path.or(gb.deno_path),
251                python_path: ws.python_path.or(gb.python_path),
252                searx_url: ws.searx_url.or(gb.searx_url),
253                auto_start_searx: ws.auto_start_searx, // Workspace setting always takes priority.
254                auto_stop_searx: ws.auto_stop_searx,   // Workspace setting always takes priority.
255                gemma_native_auto: ws.gemma_native_auto,
256                gemma_native_formatting: ws.gemma_native_formatting,
257                ..ws
258            }
259        }
260        (Some(ws), None) => ws,
261        (None, Some(gb)) => gb,
262        (None, None) => HematiteConfig::default(),
263    }
264}
265
266pub fn save_config(config: &HematiteConfig) -> Result<(), String> {
267    let path = settings_path();
268    if let Some(parent) = path.parent() {
269        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
270    }
271    let json = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
272    std::fs::write(&path, json).map_err(|e| e.to_string())?;
273    invalidate_config_cache();
274    Ok(())
275}
276
277pub fn provider_label_for_api_url(url: &str) -> &'static str {
278    let normalized = url.trim().trim_end_matches('/').to_ascii_lowercase();
279    if normalized.contains("11434") || normalized.contains("ollama") {
280        "Ollama"
281    } else if normalized.contains("1234") || normalized.contains("lmstudio") {
282        "LM Studio"
283    } else {
284        "Custom"
285    }
286}
287
288pub fn default_api_url_for_provider(provider_name: &str) -> &'static str {
289    match provider_name {
290        "Ollama" => DEFAULT_OLLAMA_API_URL,
291        _ => DEFAULT_LM_STUDIO_API_URL,
292    }
293}
294
295pub fn effective_api_url(config: &HematiteConfig, cli_default: &str) -> String {
296    config
297        .api_url
298        .clone()
299        .unwrap_or_else(|| cli_default.to_string())
300}
301
302pub fn set_api_url_override(url: Option<&str>) -> Result<(), String> {
303    let mut config = load_config();
304    config.api_url = url
305        .map(str::trim)
306        .filter(|value| !value.is_empty())
307        .map(|value| value.to_string());
308    save_config(&config)
309}
310
311pub fn preferred_coding_model(config: &HematiteConfig) -> Option<String> {
312    config
313        .think_model
314        .clone()
315        .or_else(|| config.model.clone())
316        .or_else(|| config.fast_model.clone())
317}
318
319pub fn set_preferred_coding_model(model_id: Option<&str>) -> Result<(), String> {
320    let mut config = load_config();
321    let normalized = model_id
322        .map(str::trim)
323        .filter(|value| !value.is_empty())
324        .map(|value| value.to_string());
325    config.think_model = normalized.clone();
326    if normalized.is_some() {
327        config.model = None;
328    }
329    save_config(&config)
330}
331
332pub fn set_preferred_embed_model(model_id: Option<&str>) -> Result<(), String> {
333    let mut config = load_config();
334    config.embed_model = model_id
335        .map(str::trim)
336        .filter(|value| !value.is_empty())
337        .map(|value| value.to_string());
338    save_config(&config)
339}
340
341pub fn set_gemma_native_formatting(enabled: bool) -> Result<(), String> {
342    set_gemma_native_mode(if enabled { "on" } else { "off" })
343}
344
345pub fn set_gemma_native_mode(mode: &str) -> Result<(), String> {
346    let mut config = load_config();
347    match mode {
348        "on" => {
349            config.gemma_native_auto = false;
350            config.gemma_native_formatting = true;
351        }
352        "off" => {
353            config.gemma_native_auto = false;
354            config.gemma_native_formatting = false;
355        }
356        "auto" => {
357            config.gemma_native_auto = true;
358            config.gemma_native_formatting = false;
359        }
360        _ => return Err(format!("Unknown gemma native mode: {}", mode)),
361    }
362    save_config(&config)
363}
364
365pub fn set_voice(voice_id: &str) -> Result<(), String> {
366    let mut config = load_config();
367    config.voice = Some(voice_id.to_string());
368    save_config(&config)
369}
370
371pub fn effective_voice(config: &HematiteConfig) -> String {
372    config.voice.clone().unwrap_or_else(|| "af_sky".to_string())
373}
374
375pub fn effective_voice_speed(config: &HematiteConfig) -> f32 {
376    config.voice_speed.unwrap_or(1.0).clamp(0.5, 2.0)
377}
378
379pub fn effective_voice_volume(config: &HematiteConfig) -> f32 {
380    config.voice_volume.unwrap_or(1.0).clamp(0.0, 3.0)
381}
382
383pub fn effective_gemma_native_formatting(config: &HematiteConfig, model_name: &str) -> bool {
384    crate::agent::inference::is_hematite_native_model(model_name)
385        && (config.gemma_native_formatting || config.gemma_native_auto)
386}
387
388pub fn gemma_native_mode_label(config: &HematiteConfig, model_name: &str) -> &'static str {
389    if !crate::agent::inference::is_hematite_native_model(model_name) {
390        "inactive"
391    } else if config.gemma_native_formatting {
392        "on"
393    } else if config.gemma_native_auto {
394        "auto"
395    } else {
396        "off"
397    }
398}
399
400/// Write a commented default config on first run so users know what's available.
401fn write_default_config(path: &std::path::Path) {
402    if let Some(parent) = path.parent() {
403        let _ = std::fs::create_dir_all(parent);
404    }
405    let default = r#"{
406  "_comment": "Hematite settings — edit and save, changes apply immediately without restart.",
407
408  "permissions": {
409    "allow": [
410      "cargo *",
411      "git status",
412      "git log *",
413      "git diff *",
414      "git branch *"
415    ],
416    "ask": [],
417    "deny": []
418  },
419
420  "trust": {
421    "allow": ["."],
422    "deny": []
423  },
424
425  "auto_approve_moderate": false,
426
427  "api_url": null,
428  "voice": null,
429  "voice_speed": null,
430  "voice_volume": null,
431  "context_hint": null,
432  "model": null,
433  "fast_model": null,
434  "think_model": null,
435  "embed_model": null,
436  "gemma_native_auto": true,
437  "gemma_native_formatting": false,
438  "searx_url": null,
439  "auto_start_searx": true,
440  "auto_stop_searx": false,
441
442  "verify": {
443    "default_profile": null,
444    "profiles": {
445      "rust": {
446        "build": "cargo build --color never",
447        "test": "cargo test --color never",
448        "lint": "cargo clippy --all-targets --all-features -- -D warnings",
449        "fix": "cargo fmt",
450        "timeout_secs": 120
451      }
452    }
453  },
454
455  "hooks": {
456    "pre_tool_use": [],
457    "post_tool_use": []
458  }
459  }
460"#;
461    let _ = std::fs::write(path, default);
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn provider_label_for_api_url_detects_known_runtimes() {
470        assert_eq!(
471            provider_label_for_api_url("http://localhost:1234/v1"),
472            "LM Studio"
473        );
474        assert_eq!(
475            provider_label_for_api_url("http://localhost:11434/v1"),
476            "Ollama"
477        );
478        assert_eq!(
479            provider_label_for_api_url("https://ai.example.com/v1"),
480            "Custom"
481        );
482    }
483
484    #[test]
485    fn default_api_url_for_provider_maps_presets() {
486        assert_eq!(
487            default_api_url_for_provider("LM Studio"),
488            DEFAULT_LM_STUDIO_API_URL
489        );
490        assert_eq!(
491            default_api_url_for_provider("Ollama"),
492            DEFAULT_OLLAMA_API_URL
493        );
494        assert_eq!(
495            default_api_url_for_provider("Custom"),
496            DEFAULT_LM_STUDIO_API_URL
497        );
498    }
499
500    #[test]
501    #[allow(clippy::field_reassign_with_default)]
502    fn preferred_coding_model_prefers_think_then_model_then_fast() {
503        let mut config = HematiteConfig::default();
504        config.fast_model = Some("fast".into());
505        assert_eq!(preferred_coding_model(&config), Some("fast".to_string()));
506
507        config.model = Some("main".into());
508        assert_eq!(preferred_coding_model(&config), Some("main".to_string()));
509
510        config.think_model = Some("think".into());
511        assert_eq!(preferred_coding_model(&config), Some("think".to_string()));
512    }
513}
514
515/// Returns the permission decision for a shell command given the loaded config.
516///
517/// Priority order (highest first):
518/// 1. deny rules  → always block (return true = needs approval / will be rejected)
519/// 2. allow rules → always approve (return false)
520/// 3. ask rules   → always ask (return true)
521/// 4. intrinsic risk classifier
522pub fn permission_for_shell(cmd: &str, config: &HematiteConfig) -> PermissionDecision {
523    if let Some(rules) = &config.permissions {
524        for pattern in &rules.deny {
525            if glob_matches(pattern, cmd) {
526                return PermissionDecision::Deny;
527            }
528        }
529        for pattern in &rules.allow {
530            if glob_matches(pattern, cmd) {
531                return PermissionDecision::Allow;
532            }
533        }
534        for pattern in &rules.ask {
535            if glob_matches(pattern, cmd) {
536                return PermissionDecision::Ask;
537            }
538        }
539    }
540    PermissionDecision::UseRiskClassifier
541}
542
543#[derive(Debug, PartialEq)]
544pub enum PermissionDecision {
545    Allow,
546    Deny,
547    Ask,
548    UseRiskClassifier,
549}
550
551/// Simple glob matcher: `*` is a wildcard, matching is case-insensitive.
552/// `cargo *` matches `cargo build`, `cargo check --all-targets`, etc.
553pub fn glob_matches(pattern: &str, text: &str) -> bool {
554    let p = pattern.to_lowercase();
555    let t = text.to_lowercase();
556    if p == "*" {
557        return true;
558    }
559    if let Some(star) = p.find('*') {
560        let prefix = &p[..star];
561        let suffix = &p[star + 1..];
562        t.starts_with(prefix) && (suffix.is_empty() || t.ends_with(suffix))
563    } else {
564        t.contains(&p)
565    }
566}