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
295/// Returns true if the URL host resolves to a loopback address (localhost, 127.x, ::1).
296/// Used to gate warnings when a workspace config redirects inference to a remote host.
297pub fn api_url_is_local(url: &str) -> bool {
298    let lower = url.trim().to_ascii_lowercase();
299    let lower = lower.trim_end_matches('/');
300    // Strip scheme prefix for host comparison
301    let host_part = lower
302        .strip_prefix("https://")
303        .or_else(|| lower.strip_prefix("http://"))
304        .unwrap_or(lower);
305    // Grab just the authority (before the first path slash)
306    let authority = host_part.split('/').next().unwrap_or("");
307    // Handle bracketed IPv6: [::1] or [::1]:port
308    let host = if authority.starts_with('[') {
309        authority
310            .trim_start_matches('[')
311            .split(']')
312            .next()
313            .unwrap_or("")
314    } else {
315        // IPv4 or hostname — strip port by taking everything before the last ':'
316        // only when what remains looks like a host (not an IPv6 raw address)
317        if authority.contains("::") {
318            // Bare IPv6 without brackets (non-standard but handle gracefully)
319            authority.split('/').next().unwrap_or(authority)
320        } else {
321            authority.split(':').next().unwrap_or(authority)
322        }
323    };
324    host == "localhost"
325        || host == "127.0.0.1"
326        || host.starts_with("127.")
327        || host == "::1"
328        || host == "[::1]"
329}
330
331pub fn effective_api_url(config: &HematiteConfig, cli_default: &str) -> String {
332    match &config.api_url {
333        Some(url) if !api_url_is_local(url) => {
334            eprintln!(
335                "[hematite] WARNING: workspace settings.json is redirecting the inference \
336                 endpoint to a remote host: {url}. Verify this is intentional."
337            );
338            url.clone()
339        }
340        Some(url) => url.clone(),
341        None => cli_default.to_string(),
342    }
343}
344
345pub fn set_api_url_override(url: Option<&str>) -> Result<(), String> {
346    let mut config = load_config();
347    config.api_url = url
348        .map(str::trim)
349        .filter(|value| !value.is_empty())
350        .map(|value| value.to_string());
351    save_config(&config)
352}
353
354pub fn preferred_coding_model(config: &HematiteConfig) -> Option<String> {
355    config
356        .think_model
357        .clone()
358        .or_else(|| config.model.clone())
359        .or_else(|| config.fast_model.clone())
360}
361
362pub fn set_preferred_coding_model(model_id: Option<&str>) -> Result<(), String> {
363    let mut config = load_config();
364    let normalized = model_id
365        .map(str::trim)
366        .filter(|value| !value.is_empty())
367        .map(|value| value.to_string());
368    config.think_model = normalized.clone();
369    if normalized.is_some() {
370        config.model = None;
371    }
372    save_config(&config)
373}
374
375pub fn set_preferred_embed_model(model_id: Option<&str>) -> Result<(), String> {
376    let mut config = load_config();
377    config.embed_model = model_id
378        .map(str::trim)
379        .filter(|value| !value.is_empty())
380        .map(|value| value.to_string());
381    save_config(&config)
382}
383
384pub fn set_gemma_native_formatting(enabled: bool) -> Result<(), String> {
385    set_gemma_native_mode(if enabled { "on" } else { "off" })
386}
387
388pub fn set_gemma_native_mode(mode: &str) -> Result<(), String> {
389    let mut config = load_config();
390    match mode {
391        "on" => {
392            config.gemma_native_auto = false;
393            config.gemma_native_formatting = true;
394        }
395        "off" => {
396            config.gemma_native_auto = false;
397            config.gemma_native_formatting = false;
398        }
399        "auto" => {
400            config.gemma_native_auto = true;
401            config.gemma_native_formatting = false;
402        }
403        _ => return Err(format!("Unknown gemma native mode: {}", mode)),
404    }
405    save_config(&config)
406}
407
408pub fn set_voice(voice_id: &str) -> Result<(), String> {
409    let mut config = load_config();
410    config.voice = Some(voice_id.to_string());
411    save_config(&config)
412}
413
414pub fn effective_voice(config: &HematiteConfig) -> String {
415    config.voice.clone().unwrap_or_else(|| "af_sky".to_string())
416}
417
418pub fn effective_voice_speed(config: &HematiteConfig) -> f32 {
419    config.voice_speed.unwrap_or(1.0).clamp(0.5, 2.0)
420}
421
422pub fn effective_voice_volume(config: &HematiteConfig) -> f32 {
423    config.voice_volume.unwrap_or(1.0).clamp(0.0, 3.0)
424}
425
426pub fn effective_gemma_native_formatting(config: &HematiteConfig, model_name: &str) -> bool {
427    crate::agent::inference::is_hematite_native_model(model_name)
428        && (config.gemma_native_formatting || config.gemma_native_auto)
429}
430
431pub fn gemma_native_mode_label(config: &HematiteConfig, model_name: &str) -> &'static str {
432    if !crate::agent::inference::is_hematite_native_model(model_name) {
433        "inactive"
434    } else if config.gemma_native_formatting {
435        "on"
436    } else if config.gemma_native_auto {
437        "auto"
438    } else {
439        "off"
440    }
441}
442
443/// Write a commented default config on first run so users know what's available.
444fn write_default_config(path: &std::path::Path) {
445    if let Some(parent) = path.parent() {
446        let _ = std::fs::create_dir_all(parent);
447    }
448    let default = r#"{
449  "_comment": "Hematite settings — edit and save, changes apply immediately without restart.",
450
451  "permissions": {
452    "allow": [
453      "cargo *",
454      "git status",
455      "git log *",
456      "git diff *",
457      "git branch *"
458    ],
459    "ask": [],
460    "deny": []
461  },
462
463  "trust": {
464    "allow": ["."],
465    "deny": []
466  },
467
468  "auto_approve_moderate": false,
469
470  "api_url": null,
471  "voice": null,
472  "voice_speed": null,
473  "voice_volume": null,
474  "context_hint": null,
475  "model": null,
476  "fast_model": null,
477  "think_model": null,
478  "embed_model": null,
479  "gemma_native_auto": true,
480  "gemma_native_formatting": false,
481  "searx_url": null,
482  "auto_start_searx": true,
483  "auto_stop_searx": false,
484
485  "verify": {
486    "default_profile": null,
487    "profiles": {
488      "rust": {
489        "build": "cargo build --color never",
490        "test": "cargo test --color never",
491        "lint": "cargo clippy --all-targets --all-features -- -D warnings",
492        "fix": "cargo fmt",
493        "timeout_secs": 120
494      }
495    }
496  },
497
498  "hooks": {
499    "pre_tool_use": [],
500    "post_tool_use": []
501  }
502  }
503"#;
504    let _ = std::fs::write(path, default);
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn provider_label_for_api_url_detects_known_runtimes() {
513        assert_eq!(
514            provider_label_for_api_url("http://localhost:1234/v1"),
515            "LM Studio"
516        );
517        assert_eq!(
518            provider_label_for_api_url("http://localhost:11434/v1"),
519            "Ollama"
520        );
521        assert_eq!(
522            provider_label_for_api_url("https://ai.example.com/v1"),
523            "Custom"
524        );
525    }
526
527    #[test]
528    fn default_api_url_for_provider_maps_presets() {
529        assert_eq!(
530            default_api_url_for_provider("LM Studio"),
531            DEFAULT_LM_STUDIO_API_URL
532        );
533        assert_eq!(
534            default_api_url_for_provider("Ollama"),
535            DEFAULT_OLLAMA_API_URL
536        );
537        assert_eq!(
538            default_api_url_for_provider("Custom"),
539            DEFAULT_LM_STUDIO_API_URL
540        );
541    }
542
543    #[test]
544    #[allow(clippy::field_reassign_with_default)]
545    fn preferred_coding_model_prefers_think_then_model_then_fast() {
546        let mut config = HematiteConfig::default();
547        config.fast_model = Some("fast".into());
548        assert_eq!(preferred_coding_model(&config), Some("fast".to_string()));
549
550        config.model = Some("main".into());
551        assert_eq!(preferred_coding_model(&config), Some("main".to_string()));
552
553        config.think_model = Some("think".into());
554        assert_eq!(preferred_coding_model(&config), Some("think".to_string()));
555    }
556}
557
558/// Returns the permission decision for a shell command given the loaded config.
559///
560/// Priority order (highest first):
561/// 1. deny rules  → always block (return true = needs approval / will be rejected)
562/// 2. allow rules → always approve (return false)
563/// 3. ask rules   → always ask (return true)
564/// 4. intrinsic risk classifier
565pub fn permission_for_shell(cmd: &str, config: &HematiteConfig) -> PermissionDecision {
566    if let Some(rules) = &config.permissions {
567        for pattern in &rules.deny {
568            if glob_matches(pattern, cmd) {
569                return PermissionDecision::Deny;
570            }
571        }
572        for pattern in &rules.allow {
573            if glob_matches(pattern, cmd) {
574                return PermissionDecision::Allow;
575            }
576        }
577        for pattern in &rules.ask {
578            if glob_matches(pattern, cmd) {
579                return PermissionDecision::Ask;
580            }
581        }
582    }
583    PermissionDecision::UseRiskClassifier
584}
585
586#[derive(Debug, PartialEq)]
587pub enum PermissionDecision {
588    Allow,
589    Deny,
590    Ask,
591    UseRiskClassifier,
592}
593
594/// Simple glob matcher: `*` is a wildcard, matching is case-insensitive.
595/// `cargo *` matches `cargo build`, `cargo check --all-targets`, etc.
596pub fn glob_matches(pattern: &str, text: &str) -> bool {
597    let p = pattern.to_lowercase();
598    let t = text.to_lowercase();
599    if p == "*" {
600        return true;
601    }
602    if let Some(star) = p.find('*') {
603        let prefix = &p[..star];
604        let suffix = &p[star + 1..];
605        t.starts_with(prefix) && (suffix.is_empty() || t.ends_with(suffix))
606    } else {
607        t.contains(&p)
608    }
609}