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
8fn default_true() -> bool {
9    true
10}
11
12#[derive(Serialize, Deserialize, Default, Clone, Copy, Debug, PartialEq)]
13pub enum PermissionMode {
14    #[default]
15    Developer,
16    ReadOnly,
17    SystemAdmin,
18}
19
20#[derive(Serialize, Deserialize, Default, Clone, Debug)]
21pub struct HematiteConfig {
22    /// Active authority mode.
23    #[serde(default)]
24    pub mode: PermissionMode,
25    /// Pattern-based permission overrides.
26    pub permissions: Option<PermissionRules>,
27    /// Workspace trust policy for the current project root.
28    #[serde(default)]
29    pub trust: WorkspaceTrustConfig,
30    /// Override the primary model ID (e.g. "gemma-4-e4b").
31    pub model: Option<String>,
32    /// Override the fast model ID used for read-only tasks.
33    pub fast_model: Option<String>,
34    /// Override the think model ID used for complex tasks.
35    pub think_model: Option<String>,
36    /// When true, Gemma 4 models enable native-formatting behavior automatically unless explicitly forced off.
37    #[serde(default = "default_true")]
38    pub gemma_native_auto: bool,
39    /// Force Gemma-native request shaping on for Gemma 4 models.
40    #[serde(default)]
41    pub gemma_native_formatting: bool,
42    /// Override the LLM provider base URL (e.g. "http://localhost:11434/v1" for Ollama).
43    /// Defaults to "http://localhost:1234/v1" (LM Studio). Takes precedence over --url CLI flag.
44    pub api_url: Option<String>,
45    /// Voice ID for TTS. Use /voice in the TUI to list and select. Defaults to "af_sky".
46    pub voice: Option<String>,
47    /// TTS speech speed multiplier. 1.0 = normal, 0.8 = slower, 1.3 = faster. Defaults to 1.0.
48    pub voice_speed: Option<f32>,
49    /// TTS volume. 0.0 = silent, 1.0 = normal, 2.0 = louder. Defaults to 1.0.
50    pub voice_volume: Option<f32>,
51    /// Extra text appended verbatim to the system prompt (project notes, conventions, etc.).
52    pub context_hint: Option<String>,
53    /// Override path to the Deno executable for the run_code sandbox.
54    /// If unset, Hematite checks LM Studio's bundled Deno, then system PATH.
55    /// Example: "C:/Users/you/.deno/bin/deno.exe"
56    pub deno_path: Option<String>,
57    /// Per-project verification commands for build/test/lint/fix workflows.
58    #[serde(default)]
59    pub verify: VerifyProfilesConfig,
60    /// Tool Lifecycle Hooks for automated pre/post scripts.
61    #[serde(default)]
62    pub hooks: crate::agent::hooks::RuntimeHookConfig,
63    /// Optional local SearXNG URL (e.g. "http://localhost:8080") for private research.
64    /// If set, research_web will prioritize this endpoint over external search proxies.
65    pub searx_url: Option<String>,
66    /// When true, Hematite will attempt to automatically start SearXNG on startup if it's offline.
67    #[serde(default = "default_true")]
68    pub auto_start_searx: bool,
69}
70
71#[derive(Serialize, Deserialize, Clone, Debug)]
72pub struct WorkspaceTrustConfig {
73    /// Workspace roots trusted for normal destructive and external tool posture.
74    #[serde(default = "default_trusted_workspace_roots")]
75    pub allow: Vec<String>,
76    /// Workspace roots explicitly denied for destructive and external tool posture.
77    #[serde(default)]
78    pub deny: Vec<String>,
79}
80
81impl Default for WorkspaceTrustConfig {
82    fn default() -> Self {
83        Self {
84            allow: default_trusted_workspace_roots(),
85            deny: Vec::new(),
86        }
87    }
88}
89
90fn default_trusted_workspace_roots() -> Vec<String> {
91    vec![".".to_string()]
92}
93
94#[derive(Serialize, Deserialize, Default, Clone, Debug)]
95pub struct VerifyProfilesConfig {
96    /// Optional default profile name to use when verify_build is called without an explicit profile.
97    pub default_profile: Option<String>,
98    /// Named verification profiles keyed by stack or workspace role.
99    #[serde(default)]
100    pub profiles: BTreeMap<String, VerifyProfile>,
101}
102
103#[derive(Serialize, Deserialize, Default, Clone, Debug)]
104pub struct VerifyProfile {
105    /// Build/compile validation command.
106    pub build: Option<String>,
107    /// Test command.
108    pub test: Option<String>,
109    /// Lint/static analysis command.
110    pub lint: Option<String>,
111    /// Optional auto-fix command, typically lint --fix or formatter repair.
112    pub fix: Option<String>,
113    /// Optional timeout override for this profile.
114    pub timeout_secs: Option<u64>,
115}
116
117#[derive(Serialize, Deserialize, Default, Clone, Debug)]
118pub struct PermissionRules {
119    /// Always auto-approve these patterns (e.g. "cargo *", "git status").
120    #[serde(default)]
121    pub allow: Vec<String>,
122    /// Always require approval for these patterns (e.g. "git push *").
123    #[serde(default)]
124    pub ask: Vec<String>,
125    /// Always deny these patterns outright (e.g. "rm -rf *").
126    #[serde(default)]
127    pub deny: Vec<String>,
128}
129
130pub fn settings_path() -> std::path::PathBuf {
131    crate::tools::file_ops::hematite_dir().join("settings.json")
132}
133
134/// Load global settings from `~/.hematite/settings.json` if present.
135fn load_global_config() -> Option<HematiteConfig> {
136    let home = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME"))?;
137    let path = std::path::PathBuf::from(home)
138        .join(".hematite")
139        .join("settings.json");
140    let data = std::fs::read_to_string(&path).ok()?;
141    serde_json::from_str(&data).ok()
142}
143
144/// Load `.hematite/settings.json` from the workspace root, with global
145/// `~/.hematite/settings.json` as a fallback for unset fields.
146/// Workspace config always wins; global fills in what workspace doesn't set.
147pub fn load_config() -> HematiteConfig {
148    let path = settings_path();
149
150    let workspace: Option<HematiteConfig> = if path.exists() {
151        std::fs::read_to_string(&path)
152            .ok()
153            .and_then(|d| serde_json::from_str(&d).ok())
154    } else {
155        write_default_config(&path);
156        None
157    };
158
159    let global = load_global_config();
160
161    match (workspace, global) {
162        (Some(ws), Some(gb)) => {
163            // Workspace wins on every field that isn't the zero/null default
164            HematiteConfig {
165                model: ws.model.or(gb.model),
166                fast_model: ws.fast_model.or(gb.fast_model),
167                think_model: ws.think_model.or(gb.think_model),
168                api_url: ws.api_url.or(gb.api_url),
169                voice: if ws.voice != HematiteConfig::default().voice {
170                    ws.voice
171                } else {
172                    gb.voice
173                },
174                voice_speed: ws.voice_speed.or(gb.voice_speed),
175                voice_volume: ws.voice_volume.or(gb.voice_volume),
176                context_hint: ws.context_hint.or(gb.context_hint),
177                searx_url: ws.searx_url.or(gb.searx_url),
178                auto_start_searx: ws.auto_start_searx, // Workspace setting always takes priority.
179                gemma_native_auto: ws.gemma_native_auto,
180                gemma_native_formatting: ws.gemma_native_formatting,
181                ..ws
182            }
183        }
184        (Some(ws), None) => ws,
185        (None, Some(gb)) => gb,
186        (None, None) => HematiteConfig::default(),
187    }
188}
189
190pub fn save_config(config: &HematiteConfig) -> Result<(), String> {
191    let path = settings_path();
192    if let Some(parent) = path.parent() {
193        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
194    }
195    let json = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
196    std::fs::write(&path, json).map_err(|e| e.to_string())
197}
198
199pub fn set_gemma_native_formatting(enabled: bool) -> Result<(), String> {
200    set_gemma_native_mode(if enabled { "on" } else { "off" })
201}
202
203pub fn set_gemma_native_mode(mode: &str) -> Result<(), String> {
204    let mut config = load_config();
205    match mode {
206        "on" => {
207            config.gemma_native_auto = false;
208            config.gemma_native_formatting = true;
209        }
210        "off" => {
211            config.gemma_native_auto = false;
212            config.gemma_native_formatting = false;
213        }
214        "auto" => {
215            config.gemma_native_auto = true;
216            config.gemma_native_formatting = false;
217        }
218        _ => return Err(format!("Unknown gemma native mode: {}", mode)),
219    }
220    save_config(&config)
221}
222
223pub fn set_voice(voice_id: &str) -> Result<(), String> {
224    let mut config = load_config();
225    config.voice = Some(voice_id.to_string());
226    save_config(&config)
227}
228
229pub fn effective_voice(config: &HematiteConfig) -> String {
230    config.voice.clone().unwrap_or_else(|| "af_sky".to_string())
231}
232
233pub fn effective_voice_speed(config: &HematiteConfig) -> f32 {
234    config.voice_speed.unwrap_or(1.0).clamp(0.5, 2.0)
235}
236
237pub fn effective_voice_volume(config: &HematiteConfig) -> f32 {
238    config.voice_volume.unwrap_or(1.0).clamp(0.0, 3.0)
239}
240
241pub fn effective_gemma_native_formatting(config: &HematiteConfig, model_name: &str) -> bool {
242    crate::agent::inference::is_hematite_native_model(model_name)
243        && (config.gemma_native_formatting || config.gemma_native_auto)
244}
245
246pub fn gemma_native_mode_label(config: &HematiteConfig, model_name: &str) -> &'static str {
247    if !crate::agent::inference::is_hematite_native_model(model_name) {
248        "inactive"
249    } else if config.gemma_native_formatting {
250        "on"
251    } else if config.gemma_native_auto {
252        "auto"
253    } else {
254        "off"
255    }
256}
257
258/// Write a commented default config on first run so users know what's available.
259fn write_default_config(path: &std::path::Path) {
260    if let Some(parent) = path.parent() {
261        let _ = std::fs::create_dir_all(parent);
262    }
263    let default = r#"{
264  "_comment": "Hematite settings — edit and save, changes apply immediately without restart.",
265
266  "permissions": {
267    "allow": [
268      "cargo *",
269      "git status",
270      "git log *",
271      "git diff *",
272      "git branch *"
273    ],
274    "ask": [],
275    "deny": []
276  },
277
278  "trust": {
279    "allow": ["."],
280    "deny": []
281  },
282
283  "auto_approve_moderate": false,
284
285  "api_url": null,
286  "voice": null,
287  "voice_speed": null,
288  "voice_volume": null,
289  "context_hint": null,
290  "model": null,
291  "fast_model": null,
292  "think_model": null,
293  "gemma_native_auto": true,
294  "gemma_native_formatting": false,
295  "searx_url": null,
296
297  "verify": {
298    "default_profile": null,
299    "profiles": {
300      "rust": {
301        "build": "cargo build --color never",
302        "test": "cargo test --color never",
303        "lint": "cargo clippy --all-targets --all-features -- -D warnings",
304        "fix": "cargo fmt",
305        "timeout_secs": 120
306      }
307    }
308  },
309
310  "hooks": {
311    "pre_tool_use": [],
312    "post_tool_use": []
313  }
314}
315"#;
316    let _ = std::fs::write(path, default);
317}
318
319/// Returns the permission decision for a shell command given the loaded config.
320///
321/// Priority order (highest first):
322/// 1. deny rules  → always block (return true = needs approval / will be rejected)
323/// 2. allow rules → always approve (return false)
324/// 3. ask rules   → always ask (return true)
325/// 4. intrinsic risk classifier
326pub fn permission_for_shell(cmd: &str, config: &HematiteConfig) -> PermissionDecision {
327    if let Some(rules) = &config.permissions {
328        for pattern in &rules.deny {
329            if glob_matches(pattern, cmd) {
330                return PermissionDecision::Deny;
331            }
332        }
333        for pattern in &rules.allow {
334            if glob_matches(pattern, cmd) {
335                return PermissionDecision::Allow;
336            }
337        }
338        for pattern in &rules.ask {
339            if glob_matches(pattern, cmd) {
340                return PermissionDecision::Ask;
341            }
342        }
343    }
344    PermissionDecision::UseRiskClassifier
345}
346
347#[derive(Debug, PartialEq)]
348pub enum PermissionDecision {
349    Allow,
350    Deny,
351    Ask,
352    UseRiskClassifier,
353}
354
355/// Simple glob matcher: `*` is a wildcard, matching is case-insensitive.
356/// `cargo *` matches `cargo build`, `cargo check --all-targets`, etc.
357pub fn glob_matches(pattern: &str, text: &str) -> bool {
358    let p = pattern.to_lowercase();
359    let t = text.to_lowercase();
360    if p == "*" {
361        return true;
362    }
363    if let Some(star) = p.find('*') {
364        let prefix = &p[..star];
365        let suffix = &p[star + 1..];
366        t.starts_with(prefix) && (suffix.is_empty() || t.ends_with(suffix))
367    } else {
368        t.contains(&p)
369    }
370}