Skip to main content

kimetsu_core/
config.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{KIMETSU_SCHEMA_VERSION, KimetsuResult};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct ProjectConfig {
7    pub kimetsu: KimetsuSection,
8    pub model: ModelSection,
9    pub broker: BrokerSection,
10    pub shell: ShellSection,
11    pub ingestion: IngestionSection,
12    pub run: RunSection,
13    /// v0.8: which built-in embedding model the brain uses. The
14    /// `#[serde(default)]` keeps every pre-v0.8 project.toml loading
15    /// cleanly (they get the lean English default). Resolution
16    /// precedence is `KIMETSU_BRAIN_EMBEDDER` env > this field >
17    /// default; see `kimetsu_brain::embeddings::resolve_embedder_id`.
18    #[serde(default)]
19    pub embedder: EmbedderSection,
20}
21
22impl ProjectConfig {
23    pub fn default_for_project(project_id: impl Into<String>) -> Self {
24        Self {
25            kimetsu: KimetsuSection {
26                project_id: project_id.into(),
27                schema_version: KIMETSU_SCHEMA_VERSION,
28            },
29            model: ModelSection::default(),
30            broker: BrokerSection::default(),
31            shell: ShellSection::default(),
32            ingestion: IngestionSection::default(),
33            run: RunSection::default(),
34            embedder: EmbedderSection::default(),
35        }
36    }
37
38    pub fn from_toml(value: &str) -> KimetsuResult<Self> {
39        Ok(toml::from_str(value)?)
40    }
41
42    pub fn to_toml(&self) -> KimetsuResult<String> {
43        Ok(toml::to_string_pretty(self)?)
44    }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct KimetsuSection {
49    pub project_id: String,
50    pub schema_version: i64,
51}
52
53/// v0.8: embedding-model selection. `model` is one of the curated
54/// built-in ids exposed by `kimetsu brain model list`
55/// (`bge-small-en-v1.5`, `bge-m3`, `jina-v2-base-code`). Switching
56/// changes the vector dimension, so a `kimetsu brain reindex` is
57/// required for cosine retrieval to use the new model.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct EmbedderSection {
60    #[serde(default = "default_embedder_id")]
61    pub model: String,
62}
63
64fn default_embedder_id() -> String {
65    "bge-small-en-v1.5".to_string()
66}
67
68impl Default for EmbedderSection {
69    fn default() -> Self {
70        Self {
71            model: default_embedder_id(),
72        }
73    }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ModelSection {
78    pub provider: String,
79    pub model: String,
80    pub api_key_env: String,
81    pub max_output_tokens: u32,
82    pub temperature: f32,
83    pub request_timeout_secs: u64,
84}
85
86impl Default for ModelSection {
87    fn default() -> Self {
88        Self {
89            provider: "anthropic".to_string(),
90            model: "claude-opus-4-7".to_string(),
91            api_key_env: "ANTHROPIC_API_KEY".to_string(),
92            max_output_tokens: 8192,
93            temperature: 0.2,
94            request_timeout_secs: 120,
95        }
96    }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct BrokerSection {
101    pub default_budget_tokens: u32,
102    pub weights: BrokerWeights,
103}
104
105impl Default for BrokerSection {
106    fn default() -> Self {
107        Self {
108            default_budget_tokens: 6000,
109            weights: BrokerWeights::default(),
110        }
111    }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct BrokerWeights {
116    pub relevance: f32,
117    pub confidence: f32,
118    pub freshness: f32,
119    pub scope: f32,
120    pub localization: Option<StageWeights>,
121    pub patch_plan: Option<StageWeights>,
122    pub verification: Option<StageWeights>,
123    pub review: Option<StageWeights>,
124    /// v0.5.1: half-life (in days) for the usefulness-decay
125    /// multiplier. A memory's effective usefulness contribution
126    /// decays as `exp(-ln(2) * age_days / half_life)` where age is
127    /// measured from `last_useful_at` if present, else
128    /// `created_at`. 30 days = a 6-month-old useful memory ends
129    /// up at ~1.5% of its original weight; tune lower for faster-
130    /// changing repos, higher for slow-evolving ones.
131    ///
132    /// `#[serde(default)]` keeps pre-v0.5.1 project.toml files
133    /// loading cleanly — they get the 30-day default.
134    #[serde(default = "default_decay_half_life_days")]
135    pub decay_half_life_days: f32,
136}
137
138fn default_decay_half_life_days() -> f32 {
139    30.0
140}
141
142impl Default for BrokerWeights {
143    fn default() -> Self {
144        Self {
145            relevance: 0.50,
146            confidence: 0.20,
147            freshness: 0.20,
148            scope: 0.10,
149            localization: Some(StageWeights {
150                relevance: 0.70,
151                confidence: 0.10,
152                freshness: 0.10,
153                scope: 0.10,
154            }),
155            patch_plan: Some(StageWeights {
156                relevance: 0.40,
157                confidence: 0.30,
158                freshness: 0.10,
159                scope: 0.20,
160            }),
161            verification: Some(StageWeights {
162                relevance: 0.40,
163                confidence: 0.10,
164                freshness: 0.40,
165                scope: 0.10,
166            }),
167            review: Some(StageWeights {
168                relevance: 0.50,
169                confidence: 0.20,
170                freshness: 0.20,
171                scope: 0.10,
172            }),
173            decay_half_life_days: default_decay_half_life_days(),
174        }
175    }
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct StageWeights {
180    pub relevance: f32,
181    pub confidence: f32,
182    pub freshness: f32,
183    pub scope: f32,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct ShellSection {
188    pub default_timeout_secs: u64,
189    pub max_timeout_secs: u64,
190    pub env_allowlist_extra: Vec<String>,
191    pub redact_secrets: bool,
192}
193
194impl Default for ShellSection {
195    fn default() -> Self {
196        Self {
197            default_timeout_secs: 60,
198            max_timeout_secs: 600,
199            env_allowlist_extra: vec!["RUSTFLAGS".to_string(), "CARGO_HOME".to_string()],
200            redact_secrets: true,
201        }
202    }
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct IngestionSection {
207    pub max_file_bytes: u64,
208    pub extra_skip_dirs: Vec<String>,
209    pub max_total_files: u64,
210}
211
212impl Default for IngestionSection {
213    fn default() -> Self {
214        Self {
215            max_file_bytes: 524_288,
216            extra_skip_dirs: Vec::new(),
217            max_total_files: 50_000,
218        }
219    }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct RunSection {
224    pub max_total_tool_calls: u32,
225    pub max_total_model_turns: u32,
226    pub max_total_cost_usd: f32,
227}
228
229impl Default for RunSection {
230    fn default() -> Self {
231        // `max_total_cost_usd` is treated as advisory under subscription-based
232        // providers (e.g. Claude Code OAuth). The agent loop still enforces it
233        // when it does fire, but the default is set high enough that it
234        // functions as a runaway-prevention safety net rather than a per-run
235        // budget. Tighten in `project.toml` when running against a metered
236        // provider.
237        Self {
238            max_total_tool_calls: 60,
239            max_total_model_turns: 30,
240            max_total_cost_usd: 250.0,
241        }
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    /// A pre-v0.8 project.toml has no `[embedder]` table. The
250    /// `#[serde(default)]` on the field must keep it loading cleanly,
251    /// defaulting to the lean English model.
252    #[test]
253    fn pre_v0_8_config_without_embedder_loads_with_default() {
254        let toml = r#"
255[kimetsu]
256project_id = "demo"
257schema_version = 7
258
259[model]
260provider = "anthropic"
261model = "claude-opus-4-7"
262api_key_env = "ANTHROPIC_API_KEY"
263max_output_tokens = 8192
264temperature = 0.2
265request_timeout_secs = 120
266
267[broker]
268default_budget_tokens = 6000
269
270[broker.weights]
271relevance = 0.5
272confidence = 0.2
273freshness = 0.2
274scope = 0.1
275
276[shell]
277default_timeout_secs = 60
278max_timeout_secs = 600
279env_allowlist_extra = []
280redact_secrets = true
281
282[ingestion]
283max_file_bytes = 524288
284extra_skip_dirs = []
285max_total_files = 50000
286
287[run]
288max_total_tool_calls = 60
289max_total_model_turns = 30
290max_total_cost_usd = 250.0
291"#;
292        let config = ProjectConfig::from_toml(toml).expect("pre-v0.8 toml must load");
293        assert_eq!(config.embedder.model, "bge-small-en-v1.5");
294    }
295
296    /// `model set` writes the whole config back via `to_toml`; a
297    /// round-trip must preserve the chosen embedder (and other sections).
298    #[test]
299    fn embedder_survives_toml_round_trip() {
300        let mut config = ProjectConfig::default_for_project("demo");
301        config.embedder.model = "bge-m3".to_string();
302        let serialized = config.to_toml().expect("serialize");
303        let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
304        assert_eq!(reloaded.embedder.model, "bge-m3");
305        assert_eq!(reloaded.broker.default_budget_tokens, 6000);
306        assert_eq!(reloaded.kimetsu.project_id, "demo");
307    }
308}