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    /// v0.8.5: automatic memory harvesting. `#[serde(default)]` keeps
21    /// pre-v0.8.5 project.toml files loading cleanly (they get
22    /// auto-harvest on).
23    #[serde(default)]
24    pub learning: LearningSection,
25}
26
27impl ProjectConfig {
28    pub fn default_for_project(project_id: impl Into<String>) -> Self {
29        Self {
30            kimetsu: KimetsuSection {
31                project_id: project_id.into(),
32                schema_version: KIMETSU_SCHEMA_VERSION,
33            },
34            model: ModelSection::default(),
35            broker: BrokerSection::default(),
36            shell: ShellSection::default(),
37            ingestion: IngestionSection::default(),
38            run: RunSection::default(),
39            embedder: EmbedderSection::default(),
40            learning: LearningSection::default(),
41        }
42    }
43
44    pub fn from_toml(value: &str) -> KimetsuResult<Self> {
45        Ok(toml::from_str(value)?)
46    }
47
48    pub fn to_toml(&self) -> KimetsuResult<String> {
49        Ok(toml::to_string_pretty(self)?)
50    }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct KimetsuSection {
55    pub project_id: String,
56    pub schema_version: i64,
57}
58
59/// v0.8: embedding-model selection. `model` is one of the curated
60/// built-in ids exposed by `kimetsu brain model list`
61/// (`bge-small-en-v1.5`, `bge-m3`, `jina-v2-base-code`). Switching
62/// changes the vector dimension, so a `kimetsu brain reindex` is
63/// required for cosine retrieval to use the new model.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct EmbedderSection {
66    #[serde(default = "default_embedder_id")]
67    pub model: String,
68}
69
70fn default_embedder_id() -> String {
71    "bge-small-en-v1.5".to_string()
72}
73
74impl Default for EmbedderSection {
75    fn default() -> Self {
76        Self {
77            model: default_embedder_id(),
78        }
79    }
80}
81
82/// v0.8.5: automatic memory harvesting. When `auto_harvest` is on, the
83/// proactive PostToolUse hook and the Stop hook emit a `[kimetsu-harvest]`
84/// cue at high-signal moments (a failed-then-fixed command, or a
85/// non-trivial session that recorded nothing) telling the agent to
86/// dispatch the `kimetsu-memory-harvester` subagent. Set it false to
87/// silence those cues.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct LearningSection {
90    #[serde(default = "default_auto_harvest")]
91    pub auto_harvest: bool,
92    /// Opt-in credentialed SessionEnd distiller (configured by the install
93    /// wizard). Disabled by default; `#[serde(default)]` keeps older
94    /// project.toml files loading.
95    #[serde(default)]
96    pub distiller: DistillerSection,
97}
98
99fn default_auto_harvest() -> bool {
100    true
101}
102
103impl Default for LearningSection {
104    fn default() -> Self {
105        Self {
106            auto_harvest: default_auto_harvest(),
107            distiller: DistillerSection::default(),
108        }
109    }
110}
111
112/// Credentialed SessionEnd distiller config. Secret values (the API key,
113/// optional base URL) live in `.env` under the env-var names below; only
114/// non-secret selection lives here. `provider` is `anthropic` or `openai`.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct DistillerSection {
117    #[serde(default)]
118    pub enabled: bool,
119    #[serde(default = "default_distiller_provider")]
120    pub provider: String,
121    #[serde(default = "default_distiller_model")]
122    pub model: String,
123    #[serde(default = "default_distiller_api_key_env")]
124    pub api_key_env: String,
125    #[serde(default = "default_distiller_base_url_env")]
126    pub base_url_env: String,
127}
128
129fn default_distiller_provider() -> String {
130    "anthropic".to_string()
131}
132fn default_distiller_model() -> String {
133    "claude-haiku-4-5".to_string()
134}
135fn default_distiller_api_key_env() -> String {
136    "ANTHROPIC_API_KEY".to_string()
137}
138fn default_distiller_base_url_env() -> String {
139    "ANTHROPIC_BASE_URL".to_string()
140}
141
142impl Default for DistillerSection {
143    fn default() -> Self {
144        Self {
145            enabled: false,
146            provider: default_distiller_provider(),
147            model: default_distiller_model(),
148            api_key_env: default_distiller_api_key_env(),
149            base_url_env: default_distiller_base_url_env(),
150        }
151    }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct ModelSection {
156    pub provider: String,
157    pub model: String,
158    pub api_key_env: String,
159    pub max_output_tokens: u32,
160    pub temperature: f32,
161    pub request_timeout_secs: u64,
162}
163
164impl Default for ModelSection {
165    fn default() -> Self {
166        Self {
167            provider: "anthropic".to_string(),
168            model: "claude-opus-4-7".to_string(),
169            api_key_env: "ANTHROPIC_API_KEY".to_string(),
170            max_output_tokens: 8192,
171            temperature: 0.2,
172            request_timeout_secs: 120,
173        }
174    }
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct BrokerSection {
179    pub default_budget_tokens: u32,
180    pub weights: BrokerWeights,
181}
182
183impl Default for BrokerSection {
184    fn default() -> Self {
185        Self {
186            default_budget_tokens: 6000,
187            weights: BrokerWeights::default(),
188        }
189    }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct BrokerWeights {
194    pub relevance: f32,
195    pub confidence: f32,
196    pub freshness: f32,
197    pub scope: f32,
198    pub localization: Option<StageWeights>,
199    pub patch_plan: Option<StageWeights>,
200    pub verification: Option<StageWeights>,
201    pub review: Option<StageWeights>,
202    /// v0.5.1: half-life (in days) for the usefulness-decay
203    /// multiplier. A memory's effective usefulness contribution
204    /// decays as `exp(-ln(2) * age_days / half_life)` where age is
205    /// measured from `last_useful_at` if present, else
206    /// `created_at`. 30 days = a 6-month-old useful memory ends
207    /// up at ~1.5% of its original weight; tune lower for faster-
208    /// changing repos, higher for slow-evolving ones.
209    ///
210    /// `#[serde(default)]` keeps pre-v0.5.1 project.toml files
211    /// loading cleanly — they get the 30-day default.
212    #[serde(default = "default_decay_half_life_days")]
213    pub decay_half_life_days: f32,
214}
215
216fn default_decay_half_life_days() -> f32 {
217    30.0
218}
219
220impl Default for BrokerWeights {
221    fn default() -> Self {
222        Self {
223            relevance: 0.50,
224            confidence: 0.20,
225            freshness: 0.20,
226            scope: 0.10,
227            localization: Some(StageWeights {
228                relevance: 0.70,
229                confidence: 0.10,
230                freshness: 0.10,
231                scope: 0.10,
232            }),
233            patch_plan: Some(StageWeights {
234                relevance: 0.40,
235                confidence: 0.30,
236                freshness: 0.10,
237                scope: 0.20,
238            }),
239            verification: Some(StageWeights {
240                relevance: 0.40,
241                confidence: 0.10,
242                freshness: 0.40,
243                scope: 0.10,
244            }),
245            review: Some(StageWeights {
246                relevance: 0.50,
247                confidence: 0.20,
248                freshness: 0.20,
249                scope: 0.10,
250            }),
251            decay_half_life_days: default_decay_half_life_days(),
252        }
253    }
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct StageWeights {
258    pub relevance: f32,
259    pub confidence: f32,
260    pub freshness: f32,
261    pub scope: f32,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ShellSection {
266    pub default_timeout_secs: u64,
267    pub max_timeout_secs: u64,
268    pub env_allowlist_extra: Vec<String>,
269    pub redact_secrets: bool,
270}
271
272impl Default for ShellSection {
273    fn default() -> Self {
274        Self {
275            default_timeout_secs: 60,
276            max_timeout_secs: 600,
277            env_allowlist_extra: vec!["RUSTFLAGS".to_string(), "CARGO_HOME".to_string()],
278            redact_secrets: true,
279        }
280    }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct IngestionSection {
285    pub max_file_bytes: u64,
286    pub extra_skip_dirs: Vec<String>,
287    pub max_total_files: u64,
288}
289
290impl Default for IngestionSection {
291    fn default() -> Self {
292        Self {
293            max_file_bytes: 524_288,
294            extra_skip_dirs: Vec::new(),
295            max_total_files: 50_000,
296        }
297    }
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct RunSection {
302    pub max_total_tool_calls: u32,
303    pub max_total_model_turns: u32,
304    pub max_total_cost_usd: f32,
305}
306
307impl Default for RunSection {
308    fn default() -> Self {
309        // `max_total_cost_usd` is treated as advisory under subscription-based
310        // providers (e.g. Claude Code OAuth). The agent loop still enforces it
311        // when it does fire, but the default is set high enough that it
312        // functions as a runaway-prevention safety net rather than a per-run
313        // budget. Tighten in `project.toml` when running against a metered
314        // provider.
315        Self {
316            max_total_tool_calls: 60,
317            max_total_model_turns: 30,
318            max_total_cost_usd: 250.0,
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    /// A pre-v0.8 project.toml has no `[embedder]` table. The
328    /// `#[serde(default)]` on the field must keep it loading cleanly,
329    /// defaulting to the lean English model.
330    #[test]
331    fn pre_v0_8_config_without_embedder_loads_with_default() {
332        let toml = r#"
333[kimetsu]
334project_id = "demo"
335schema_version = 7
336
337[model]
338provider = "anthropic"
339model = "claude-opus-4-7"
340api_key_env = "ANTHROPIC_API_KEY"
341max_output_tokens = 8192
342temperature = 0.2
343request_timeout_secs = 120
344
345[broker]
346default_budget_tokens = 6000
347
348[broker.weights]
349relevance = 0.5
350confidence = 0.2
351freshness = 0.2
352scope = 0.1
353
354[shell]
355default_timeout_secs = 60
356max_timeout_secs = 600
357env_allowlist_extra = []
358redact_secrets = true
359
360[ingestion]
361max_file_bytes = 524288
362extra_skip_dirs = []
363max_total_files = 50000
364
365[run]
366max_total_tool_calls = 60
367max_total_model_turns = 30
368max_total_cost_usd = 250.0
369"#;
370        let config = ProjectConfig::from_toml(toml).expect("pre-v0.8 toml must load");
371        assert_eq!(config.embedder.model, "bge-small-en-v1.5");
372        // A pre-v0.8.5 toml has no [learning] section — auto-harvest
373        // defaults on so existing installs gain the behavior on upgrade.
374        assert!(config.learning.auto_harvest);
375        // A pre-distiller toml has no [learning.distiller] — defaults to off,
376        // anthropic, claude-haiku-4-5.
377        assert!(!config.learning.distiller.enabled);
378        assert_eq!(config.learning.distiller.provider, "anthropic");
379        assert_eq!(config.learning.distiller.model, "claude-haiku-4-5");
380        assert_eq!(config.learning.distiller.api_key_env, "ANTHROPIC_API_KEY");
381        assert_eq!(config.learning.distiller.base_url_env, "ANTHROPIC_BASE_URL");
382    }
383
384    /// `model set` writes the whole config back via `to_toml`; a
385    /// round-trip must preserve the chosen embedder (and other sections).
386    #[test]
387    fn embedder_survives_toml_round_trip() {
388        let mut config = ProjectConfig::default_for_project("demo");
389        config.embedder.model = "bge-m3".to_string();
390        let serialized = config.to_toml().expect("serialize");
391        let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
392        assert_eq!(reloaded.embedder.model, "bge-m3");
393        assert_eq!(reloaded.broker.default_budget_tokens, 6000);
394        assert_eq!(reloaded.kimetsu.project_id, "demo");
395    }
396}