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/// Re-loaded at the start of every turn so edits take effect without restart.
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
181/// Load `.hematite/settings.json` from the workspace root, with global
182/// `~/.hematite/settings.json` as a fallback for unset fields.
183/// Workspace config always wins; global fills in what workspace doesn't set.
184pub fn load_config() -> HematiteConfig {
185    let path = settings_path();
186
187    let workspace: Option<HematiteConfig> = if path.exists() {
188        let content = std::fs::read_to_string(&path).ok();
189        if let Some(d) = content {
190            match serde_json::from_str(&d) {
191                Ok(cfg) => Some(cfg),
192                Err(_) => None,
193            }
194        } else {
195            None
196        }
197    } else {
198        write_default_config(&path);
199        None
200    };
201
202    let global = load_global_config();
203
204    match (workspace, global) {
205        (Some(ws), Some(gb)) => {
206            // Workspace wins on every field that isn't the zero/null default
207            HematiteConfig {
208                model: ws.model.or(gb.model),
209                fast_model: ws.fast_model.or(gb.fast_model),
210                think_model: ws.think_model.or(gb.think_model),
211                embed_model: ws.embed_model.or(gb.embed_model),
212                api_url: ws.api_url.or(gb.api_url),
213                voice: if ws.voice != HematiteConfig::default().voice {
214                    ws.voice
215                } else {
216                    gb.voice
217                },
218                voice_speed: ws.voice_speed.or(gb.voice_speed),
219                voice_volume: ws.voice_volume.or(gb.voice_volume),
220                context_hint: ws.context_hint.or(gb.context_hint),
221                deno_path: ws.deno_path.or(gb.deno_path),
222                python_path: ws.python_path.or(gb.python_path),
223                searx_url: ws.searx_url.or(gb.searx_url),
224                auto_start_searx: ws.auto_start_searx, // Workspace setting always takes priority.
225                auto_stop_searx: ws.auto_stop_searx,   // Workspace setting always takes priority.
226                gemma_native_auto: ws.gemma_native_auto,
227                gemma_native_formatting: ws.gemma_native_formatting,
228                ..ws
229            }
230        }
231        (Some(ws), None) => ws,
232        (None, Some(gb)) => gb,
233        (None, None) => HematiteConfig::default(),
234    }
235}
236
237pub fn save_config(config: &HematiteConfig) -> Result<(), String> {
238    let path = settings_path();
239    if let Some(parent) = path.parent() {
240        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
241    }
242    let json = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
243    std::fs::write(&path, json).map_err(|e| e.to_string())
244}
245
246pub fn provider_label_for_api_url(url: &str) -> &'static str {
247    let normalized = url.trim().trim_end_matches('/').to_ascii_lowercase();
248    if normalized.contains("11434") || normalized.contains("ollama") {
249        "Ollama"
250    } else if normalized.contains("1234") || normalized.contains("lmstudio") {
251        "LM Studio"
252    } else {
253        "Custom"
254    }
255}
256
257pub fn default_api_url_for_provider(provider_name: &str) -> &'static str {
258    match provider_name {
259        "Ollama" => DEFAULT_OLLAMA_API_URL,
260        _ => DEFAULT_LM_STUDIO_API_URL,
261    }
262}
263
264pub fn effective_api_url(config: &HematiteConfig, cli_default: &str) -> String {
265    config
266        .api_url
267        .clone()
268        .unwrap_or_else(|| cli_default.to_string())
269}
270
271pub fn set_api_url_override(url: Option<&str>) -> Result<(), String> {
272    let mut config = load_config();
273    config.api_url = url
274        .map(str::trim)
275        .filter(|value| !value.is_empty())
276        .map(|value| value.to_string());
277    save_config(&config)
278}
279
280pub fn preferred_coding_model(config: &HematiteConfig) -> Option<String> {
281    config
282        .think_model
283        .clone()
284        .or(config.model.clone())
285        .or(config.fast_model.clone())
286}
287
288pub fn set_preferred_coding_model(model_id: Option<&str>) -> Result<(), String> {
289    let mut config = load_config();
290    let normalized = model_id
291        .map(str::trim)
292        .filter(|value| !value.is_empty())
293        .map(|value| value.to_string());
294    config.think_model = normalized.clone();
295    if normalized.is_some() {
296        config.model = None;
297    }
298    save_config(&config)
299}
300
301pub fn set_preferred_embed_model(model_id: Option<&str>) -> Result<(), String> {
302    let mut config = load_config();
303    config.embed_model = model_id
304        .map(str::trim)
305        .filter(|value| !value.is_empty())
306        .map(|value| value.to_string());
307    save_config(&config)
308}
309
310pub fn set_gemma_native_formatting(enabled: bool) -> Result<(), String> {
311    set_gemma_native_mode(if enabled { "on" } else { "off" })
312}
313
314pub fn set_gemma_native_mode(mode: &str) -> Result<(), String> {
315    let mut config = load_config();
316    match mode {
317        "on" => {
318            config.gemma_native_auto = false;
319            config.gemma_native_formatting = true;
320        }
321        "off" => {
322            config.gemma_native_auto = false;
323            config.gemma_native_formatting = false;
324        }
325        "auto" => {
326            config.gemma_native_auto = true;
327            config.gemma_native_formatting = false;
328        }
329        _ => return Err(format!("Unknown gemma native mode: {}", mode)),
330    }
331    save_config(&config)
332}
333
334pub fn set_voice(voice_id: &str) -> Result<(), String> {
335    let mut config = load_config();
336    config.voice = Some(voice_id.to_string());
337    save_config(&config)
338}
339
340pub fn effective_voice(config: &HematiteConfig) -> String {
341    config.voice.clone().unwrap_or_else(|| "af_sky".to_string())
342}
343
344pub fn effective_voice_speed(config: &HematiteConfig) -> f32 {
345    config.voice_speed.unwrap_or(1.0).clamp(0.5, 2.0)
346}
347
348pub fn effective_voice_volume(config: &HematiteConfig) -> f32 {
349    config.voice_volume.unwrap_or(1.0).clamp(0.0, 3.0)
350}
351
352pub fn effective_gemma_native_formatting(config: &HematiteConfig, model_name: &str) -> bool {
353    crate::agent::inference::is_hematite_native_model(model_name)
354        && (config.gemma_native_formatting || config.gemma_native_auto)
355}
356
357pub fn gemma_native_mode_label(config: &HematiteConfig, model_name: &str) -> &'static str {
358    if !crate::agent::inference::is_hematite_native_model(model_name) {
359        "inactive"
360    } else if config.gemma_native_formatting {
361        "on"
362    } else if config.gemma_native_auto {
363        "auto"
364    } else {
365        "off"
366    }
367}
368
369/// Write a commented default config on first run so users know what's available.
370fn write_default_config(path: &std::path::Path) {
371    if let Some(parent) = path.parent() {
372        let _ = std::fs::create_dir_all(parent);
373    }
374    let default = r#"{
375  "_comment": "Hematite settings — edit and save, changes apply immediately without restart.",
376
377  "permissions": {
378    "allow": [
379      "cargo *",
380      "git status",
381      "git log *",
382      "git diff *",
383      "git branch *"
384    ],
385    "ask": [],
386    "deny": []
387  },
388
389  "trust": {
390    "allow": ["."],
391    "deny": []
392  },
393
394  "auto_approve_moderate": false,
395
396  "api_url": null,
397  "voice": null,
398  "voice_speed": null,
399  "voice_volume": null,
400  "context_hint": null,
401  "model": null,
402  "fast_model": null,
403  "think_model": null,
404  "embed_model": null,
405  "gemma_native_auto": true,
406  "gemma_native_formatting": false,
407  "searx_url": null,
408  "auto_start_searx": true,
409  "auto_stop_searx": false,
410
411  "verify": {
412    "default_profile": null,
413    "profiles": {
414      "rust": {
415        "build": "cargo build --color never",
416        "test": "cargo test --color never",
417        "lint": "cargo clippy --all-targets --all-features -- -D warnings",
418        "fix": "cargo fmt",
419        "timeout_secs": 120
420      }
421    }
422  },
423
424  "hooks": {
425    "pre_tool_use": [],
426    "post_tool_use": []
427  }
428  }
429"#;
430    let _ = std::fs::write(path, default);
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn provider_label_for_api_url_detects_known_runtimes() {
439        assert_eq!(
440            provider_label_for_api_url("http://localhost:1234/v1"),
441            "LM Studio"
442        );
443        assert_eq!(
444            provider_label_for_api_url("http://localhost:11434/v1"),
445            "Ollama"
446        );
447        assert_eq!(
448            provider_label_for_api_url("https://ai.example.com/v1"),
449            "Custom"
450        );
451    }
452
453    #[test]
454    fn default_api_url_for_provider_maps_presets() {
455        assert_eq!(
456            default_api_url_for_provider("LM Studio"),
457            DEFAULT_LM_STUDIO_API_URL
458        );
459        assert_eq!(
460            default_api_url_for_provider("Ollama"),
461            DEFAULT_OLLAMA_API_URL
462        );
463        assert_eq!(
464            default_api_url_for_provider("Custom"),
465            DEFAULT_LM_STUDIO_API_URL
466        );
467    }
468
469    #[test]
470    fn preferred_coding_model_prefers_think_then_model_then_fast() {
471        let mut config = HematiteConfig::default();
472        config.fast_model = Some("fast".into());
473        assert_eq!(preferred_coding_model(&config), Some("fast".to_string()));
474
475        config.model = Some("main".into());
476        assert_eq!(preferred_coding_model(&config), Some("main".to_string()));
477
478        config.think_model = Some("think".into());
479        assert_eq!(preferred_coding_model(&config), Some("think".to_string()));
480    }
481}
482
483/// Returns the permission decision for a shell command given the loaded config.
484///
485/// Priority order (highest first):
486/// 1. deny rules  → always block (return true = needs approval / will be rejected)
487/// 2. allow rules → always approve (return false)
488/// 3. ask rules   → always ask (return true)
489/// 4. intrinsic risk classifier
490pub fn permission_for_shell(cmd: &str, config: &HematiteConfig) -> PermissionDecision {
491    if let Some(rules) = &config.permissions {
492        for pattern in &rules.deny {
493            if glob_matches(pattern, cmd) {
494                return PermissionDecision::Deny;
495            }
496        }
497        for pattern in &rules.allow {
498            if glob_matches(pattern, cmd) {
499                return PermissionDecision::Allow;
500            }
501        }
502        for pattern in &rules.ask {
503            if glob_matches(pattern, cmd) {
504                return PermissionDecision::Ask;
505            }
506        }
507    }
508    PermissionDecision::UseRiskClassifier
509}
510
511#[derive(Debug, PartialEq)]
512pub enum PermissionDecision {
513    Allow,
514    Deny,
515    Ask,
516    UseRiskClassifier,
517}
518
519/// Simple glob matcher: `*` is a wildcard, matching is case-insensitive.
520/// `cargo *` matches `cargo build`, `cargo check --all-targets`, etc.
521pub fn glob_matches(pattern: &str, text: &str) -> bool {
522    let p = pattern.to_lowercase();
523    let t = text.to_lowercase();
524    if p == "*" {
525        return true;
526    }
527    if let Some(star) = p.find('*') {
528        let prefix = &p[..star];
529        let suffix = &p[star + 1..];
530        t.starts_with(prefix) && (suffix.is_empty() || t.ends_with(suffix))
531    } else {
532        t.contains(&p)
533    }
534}