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    /// Per-project verification commands for build/test/lint/fix workflows.
63    #[serde(default)]
64    pub verify: VerifyProfilesConfig,
65    /// Tool Lifecycle Hooks for automated pre/post scripts.
66    #[serde(default)]
67    pub hooks: crate::agent::hooks::RuntimeHookConfig,
68    /// Optional local SearXNG URL (e.g. "http://localhost:8080") for private research.
69    /// If set, research_web will prioritize this endpoint over external search proxies.
70    pub searx_url: Option<String>,
71    /// When true, Hematite will attempt to automatically start SearXNG on startup if it's offline.
72    #[serde(default = "default_true")]
73    pub auto_start_searx: bool,
74    /// When true, Hematite stops a SearXNG stack on exit only if this session started it.
75    #[serde(default)]
76    pub auto_stop_searx: bool,
77}
78
79impl Default for HematiteConfig {
80    fn default() -> Self {
81        Self {
82            mode: PermissionMode::Developer,
83            permissions: None,
84            trust: WorkspaceTrustConfig::default(),
85            model: None,
86            fast_model: None,
87            think_model: None,
88            embed_model: None,
89            gemma_native_auto: true,
90            gemma_native_formatting: false,
91            api_url: None,
92            voice: None,
93            voice_speed: None,
94            voice_volume: None,
95            context_hint: None,
96            deno_path: None,
97            verify: VerifyProfilesConfig::default(),
98            hooks: crate::agent::hooks::RuntimeHookConfig::default(),
99            searx_url: None,
100            auto_start_searx: true,
101            auto_stop_searx: false,
102        }
103    }
104}
105
106#[derive(Serialize, Deserialize, Clone, Debug)]
107pub struct WorkspaceTrustConfig {
108    /// Workspace roots trusted for normal destructive and external tool posture.
109    #[serde(default = "default_trusted_workspace_roots")]
110    pub allow: Vec<String>,
111    /// Workspace roots explicitly denied for destructive and external tool posture.
112    #[serde(default)]
113    pub deny: Vec<String>,
114}
115
116impl Default for WorkspaceTrustConfig {
117    fn default() -> Self {
118        Self {
119            allow: default_trusted_workspace_roots(),
120            deny: Vec::new(),
121        }
122    }
123}
124
125fn default_trusted_workspace_roots() -> Vec<String> {
126    vec![".".to_string()]
127}
128
129#[derive(Serialize, Deserialize, Default, Clone, Debug)]
130pub struct VerifyProfilesConfig {
131    /// Optional default profile name to use when verify_build is called without an explicit profile.
132    pub default_profile: Option<String>,
133    /// Named verification profiles keyed by stack or workspace role.
134    #[serde(default)]
135    pub profiles: BTreeMap<String, VerifyProfile>,
136}
137
138#[derive(Serialize, Deserialize, Default, Clone, Debug)]
139pub struct VerifyProfile {
140    /// Build/compile validation command.
141    pub build: Option<String>,
142    /// Test command.
143    pub test: Option<String>,
144    /// Lint/static analysis command.
145    pub lint: Option<String>,
146    /// Optional auto-fix command, typically lint --fix or formatter repair.
147    pub fix: Option<String>,
148    /// Optional timeout override for this profile.
149    pub timeout_secs: Option<u64>,
150}
151
152#[derive(Serialize, Deserialize, Default, Clone, Debug)]
153pub struct PermissionRules {
154    /// Always auto-approve these patterns (e.g. "cargo *", "git status").
155    #[serde(default)]
156    pub allow: Vec<String>,
157    /// Always require approval for these patterns (e.g. "git push *").
158    #[serde(default)]
159    pub ask: Vec<String>,
160    /// Always deny these patterns outright (e.g. "rm -rf *").
161    #[serde(default)]
162    pub deny: Vec<String>,
163}
164
165pub fn settings_path() -> std::path::PathBuf {
166    crate::tools::file_ops::hematite_dir().join("settings.json")
167}
168
169/// Load global settings from `~/.hematite/settings.json` if present.
170fn load_global_config() -> Option<HematiteConfig> {
171    let home = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME"))?;
172    let path = std::path::PathBuf::from(home)
173        .join(".hematite")
174        .join("settings.json");
175    let data = std::fs::read_to_string(&path).ok()?;
176    serde_json::from_str(&data).ok()
177}
178
179/// Load `.hematite/settings.json` from the workspace root, with global
180/// `~/.hematite/settings.json` as a fallback for unset fields.
181/// Workspace config always wins; global fills in what workspace doesn't set.
182pub fn load_config() -> HematiteConfig {
183    let path = settings_path();
184
185    let workspace: Option<HematiteConfig> = if path.exists() {
186        std::fs::read_to_string(&path)
187            .ok()
188            .and_then(|d| serde_json::from_str(&d).ok())
189    } else {
190        write_default_config(&path);
191        None
192    };
193
194    let global = load_global_config();
195
196    match (workspace, global) {
197        (Some(ws), Some(gb)) => {
198            // Workspace wins on every field that isn't the zero/null default
199            HematiteConfig {
200                model: ws.model.or(gb.model),
201                fast_model: ws.fast_model.or(gb.fast_model),
202                think_model: ws.think_model.or(gb.think_model),
203                embed_model: ws.embed_model.or(gb.embed_model),
204                api_url: ws.api_url.or(gb.api_url),
205                voice: if ws.voice != HematiteConfig::default().voice {
206                    ws.voice
207                } else {
208                    gb.voice
209                },
210                voice_speed: ws.voice_speed.or(gb.voice_speed),
211                voice_volume: ws.voice_volume.or(gb.voice_volume),
212                context_hint: ws.context_hint.or(gb.context_hint),
213                searx_url: ws.searx_url.or(gb.searx_url),
214                auto_start_searx: ws.auto_start_searx, // Workspace setting always takes priority.
215                auto_stop_searx: ws.auto_stop_searx,   // Workspace setting always takes priority.
216                gemma_native_auto: ws.gemma_native_auto,
217                gemma_native_formatting: ws.gemma_native_formatting,
218                ..ws
219            }
220        }
221        (Some(ws), None) => ws,
222        (None, Some(gb)) => gb,
223        (None, None) => HematiteConfig::default(),
224    }
225}
226
227pub fn save_config(config: &HematiteConfig) -> Result<(), String> {
228    let path = settings_path();
229    if let Some(parent) = path.parent() {
230        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
231    }
232    let json = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
233    std::fs::write(&path, json).map_err(|e| e.to_string())
234}
235
236pub fn provider_label_for_api_url(url: &str) -> &'static str {
237    let normalized = url.trim().trim_end_matches('/').to_ascii_lowercase();
238    if normalized.contains("11434") || normalized.contains("ollama") {
239        "Ollama"
240    } else if normalized.contains("1234") || normalized.contains("lmstudio") {
241        "LM Studio"
242    } else {
243        "Custom"
244    }
245}
246
247pub fn default_api_url_for_provider(provider_name: &str) -> &'static str {
248    match provider_name {
249        "Ollama" => DEFAULT_OLLAMA_API_URL,
250        _ => DEFAULT_LM_STUDIO_API_URL,
251    }
252}
253
254pub fn effective_api_url(config: &HematiteConfig, cli_default: &str) -> String {
255    config
256        .api_url
257        .clone()
258        .unwrap_or_else(|| cli_default.to_string())
259}
260
261pub fn set_api_url_override(url: Option<&str>) -> Result<(), String> {
262    let mut config = load_config();
263    config.api_url = url
264        .map(str::trim)
265        .filter(|value| !value.is_empty())
266        .map(|value| value.to_string());
267    save_config(&config)
268}
269
270pub fn preferred_coding_model(config: &HematiteConfig) -> Option<String> {
271    config
272        .think_model
273        .clone()
274        .or(config.model.clone())
275        .or(config.fast_model.clone())
276}
277
278pub fn set_preferred_coding_model(model_id: Option<&str>) -> Result<(), String> {
279    let mut config = load_config();
280    let normalized = model_id
281        .map(str::trim)
282        .filter(|value| !value.is_empty())
283        .map(|value| value.to_string());
284    config.think_model = normalized.clone();
285    if normalized.is_some() {
286        config.model = None;
287    }
288    save_config(&config)
289}
290
291pub fn set_preferred_embed_model(model_id: Option<&str>) -> Result<(), String> {
292    let mut config = load_config();
293    config.embed_model = model_id
294        .map(str::trim)
295        .filter(|value| !value.is_empty())
296        .map(|value| value.to_string());
297    save_config(&config)
298}
299
300pub fn set_gemma_native_formatting(enabled: bool) -> Result<(), String> {
301    set_gemma_native_mode(if enabled { "on" } else { "off" })
302}
303
304pub fn set_gemma_native_mode(mode: &str) -> Result<(), String> {
305    let mut config = load_config();
306    match mode {
307        "on" => {
308            config.gemma_native_auto = false;
309            config.gemma_native_formatting = true;
310        }
311        "off" => {
312            config.gemma_native_auto = false;
313            config.gemma_native_formatting = false;
314        }
315        "auto" => {
316            config.gemma_native_auto = true;
317            config.gemma_native_formatting = false;
318        }
319        _ => return Err(format!("Unknown gemma native mode: {}", mode)),
320    }
321    save_config(&config)
322}
323
324pub fn set_voice(voice_id: &str) -> Result<(), String> {
325    let mut config = load_config();
326    config.voice = Some(voice_id.to_string());
327    save_config(&config)
328}
329
330pub fn effective_voice(config: &HematiteConfig) -> String {
331    config.voice.clone().unwrap_or_else(|| "af_sky".to_string())
332}
333
334pub fn effective_voice_speed(config: &HematiteConfig) -> f32 {
335    config.voice_speed.unwrap_or(1.0).clamp(0.5, 2.0)
336}
337
338pub fn effective_voice_volume(config: &HematiteConfig) -> f32 {
339    config.voice_volume.unwrap_or(1.0).clamp(0.0, 3.0)
340}
341
342pub fn effective_gemma_native_formatting(config: &HematiteConfig, model_name: &str) -> bool {
343    crate::agent::inference::is_hematite_native_model(model_name)
344        && (config.gemma_native_formatting || config.gemma_native_auto)
345}
346
347pub fn gemma_native_mode_label(config: &HematiteConfig, model_name: &str) -> &'static str {
348    if !crate::agent::inference::is_hematite_native_model(model_name) {
349        "inactive"
350    } else if config.gemma_native_formatting {
351        "on"
352    } else if config.gemma_native_auto {
353        "auto"
354    } else {
355        "off"
356    }
357}
358
359/// Write a commented default config on first run so users know what's available.
360fn write_default_config(path: &std::path::Path) {
361    if let Some(parent) = path.parent() {
362        let _ = std::fs::create_dir_all(parent);
363    }
364    let default = r#"{
365  "_comment": "Hematite settings — edit and save, changes apply immediately without restart.",
366
367  "permissions": {
368    "allow": [
369      "cargo *",
370      "git status",
371      "git log *",
372      "git diff *",
373      "git branch *"
374    ],
375    "ask": [],
376    "deny": []
377  },
378
379  "trust": {
380    "allow": ["."],
381    "deny": []
382  },
383
384  "auto_approve_moderate": false,
385
386  "api_url": null,
387  "voice": null,
388  "voice_speed": null,
389  "voice_volume": null,
390  "context_hint": null,
391  "model": null,
392  "fast_model": null,
393  "think_model": null,
394  "embed_model": null,
395  "gemma_native_auto": true,
396  "gemma_native_formatting": false,
397  "searx_url": null,
398  "auto_start_searx": true,
399  "auto_stop_searx": false,
400
401  "verify": {
402    "default_profile": null,
403    "profiles": {
404      "rust": {
405        "build": "cargo build --color never",
406        "test": "cargo test --color never",
407        "lint": "cargo clippy --all-targets --all-features -- -D warnings",
408        "fix": "cargo fmt",
409        "timeout_secs": 120
410      }
411    }
412  },
413
414  "hooks": {
415    "pre_tool_use": [],
416    "post_tool_use": []
417  }
418  }
419"#;
420    let _ = std::fs::write(path, default);
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn provider_label_for_api_url_detects_known_runtimes() {
429        assert_eq!(
430            provider_label_for_api_url("http://localhost:1234/v1"),
431            "LM Studio"
432        );
433        assert_eq!(
434            provider_label_for_api_url("http://localhost:11434/v1"),
435            "Ollama"
436        );
437        assert_eq!(
438            provider_label_for_api_url("https://ai.example.com/v1"),
439            "Custom"
440        );
441    }
442
443    #[test]
444    fn default_api_url_for_provider_maps_presets() {
445        assert_eq!(
446            default_api_url_for_provider("LM Studio"),
447            DEFAULT_LM_STUDIO_API_URL
448        );
449        assert_eq!(
450            default_api_url_for_provider("Ollama"),
451            DEFAULT_OLLAMA_API_URL
452        );
453        assert_eq!(
454            default_api_url_for_provider("Custom"),
455            DEFAULT_LM_STUDIO_API_URL
456        );
457    }
458
459    #[test]
460    fn preferred_coding_model_prefers_think_then_model_then_fast() {
461        let mut config = HematiteConfig::default();
462        config.fast_model = Some("fast".into());
463        assert_eq!(preferred_coding_model(&config), Some("fast".to_string()));
464
465        config.model = Some("main".into());
466        assert_eq!(preferred_coding_model(&config), Some("main".to_string()));
467
468        config.think_model = Some("think".into());
469        assert_eq!(preferred_coding_model(&config), Some("think".to_string()));
470    }
471}
472
473/// Returns the permission decision for a shell command given the loaded config.
474///
475/// Priority order (highest first):
476/// 1. deny rules  → always block (return true = needs approval / will be rejected)
477/// 2. allow rules → always approve (return false)
478/// 3. ask rules   → always ask (return true)
479/// 4. intrinsic risk classifier
480pub fn permission_for_shell(cmd: &str, config: &HematiteConfig) -> PermissionDecision {
481    if let Some(rules) = &config.permissions {
482        for pattern in &rules.deny {
483            if glob_matches(pattern, cmd) {
484                return PermissionDecision::Deny;
485            }
486        }
487        for pattern in &rules.allow {
488            if glob_matches(pattern, cmd) {
489                return PermissionDecision::Allow;
490            }
491        }
492        for pattern in &rules.ask {
493            if glob_matches(pattern, cmd) {
494                return PermissionDecision::Ask;
495            }
496        }
497    }
498    PermissionDecision::UseRiskClassifier
499}
500
501#[derive(Debug, PartialEq)]
502pub enum PermissionDecision {
503    Allow,
504    Deny,
505    Ask,
506    UseRiskClassifier,
507}
508
509/// Simple glob matcher: `*` is a wildcard, matching is case-insensitive.
510/// `cargo *` matches `cargo build`, `cargo check --all-targets`, etc.
511pub fn glob_matches(pattern: &str, text: &str) -> bool {
512    let p = pattern.to_lowercase();
513    let t = text.to_lowercase();
514    if p == "*" {
515        return true;
516    }
517    if let Some(star) = p.find('*') {
518        let prefix = &p[..star];
519        let suffix = &p[star + 1..];
520        t.starts_with(prefix) && (suffix.is_empty() || t.ends_with(suffix))
521    } else {
522        t.contains(&p)
523    }
524}