Skip to main content

kimetsu_core/
config.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{KIMETSU_CONFIG_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_CONFIG_VERSION,
33                use_user_brain: default_true(),
34                mcp_write_tools: default_true(),
35            },
36            model: ModelSection::default(),
37            broker: BrokerSection::default(),
38            shell: ShellSection::default(),
39            ingestion: IngestionSection::default(),
40            run: RunSection::default(),
41            embedder: EmbedderSection::default(),
42            learning: LearningSection::default(),
43        }
44    }
45
46    pub fn from_toml(value: &str) -> KimetsuResult<Self> {
47        Ok(toml::from_str(value)?)
48    }
49
50    pub fn to_toml(&self) -> KimetsuResult<String> {
51        Ok(toml::to_string_pretty(self)?)
52    }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct KimetsuSection {
57    pub project_id: String,
58    pub schema_version: i64,
59    /// W3.3: per-project opt-out of the global cross-project user brain
60    /// (`~/.kimetsu/brain.db`). When false, GlobalUser writes fall back to
61    /// the project DB and retrieval skips the user-brain merge — identical
62    /// to `KIMETSU_USER_BRAIN=0` but durable and scoped to this project.
63    ///
64    /// Precedence: `KIMETSU_USER_BRAIN` env > this field > default (true).
65    /// `#[serde(default)]` keeps all pre-W3 project.toml files loading
66    /// unchanged (they get `use_user_brain = true`).
67    #[serde(default = "default_true")]
68    pub use_user_brain: bool,
69    /// v1.0.0: allow the LOCAL stdio MCP server to expose privileged write
70    /// tools (`kimetsu_brain_record`, `memory_add/accept/reject`, …).
71    /// Default true: the local plugin install on your own machine is the
72    /// "trusted session" the gate exists for, and the brain's own workflow
73    /// (CLAUDE.md guidance, the Stop-hook harvest cue) instructs the agent
74    /// to record lessons — a default-deny gate contradicted that at every
75    /// session end. Personalize via `kimetsu config set
76    /// kimetsu.mcp_write_tools false`. Precedence:
77    /// `KIMETSU_MCP_ENABLE_WRITE_TOOLS` env (set = wins, truthy/falsy) >
78    /// this field > default (true). The REMOTE server ignores this field
79    /// entirely (a cloned repo's project.toml is untrusted there) and
80    /// stays env-only, default-deny.
81    #[serde(default = "default_true")]
82    pub mcp_write_tools: bool,
83}
84
85/// v0.8: embedding-model selection. `model` is one of the curated
86/// built-in ids exposed by `kimetsu brain model list`
87/// (`bge-small-en-v1.5`, `bge-m3`, `jina-v2-base-code`). Switching
88/// changes the vector dimension, so a `kimetsu brain reindex` is
89/// required for cosine retrieval to use the new model.
90///
91/// W3.1: `enabled` is a persistent off-switch for the embedding engine.
92/// When false, the embedder resolves to NoopEmbedder (FTS-only; no
93/// vectors written or queried). Precedence: `KIMETSU_BRAIN_EMBEDDER`
94/// env override > this field > default (true). A disable env value
95/// (`noop`/`off`/`0`/…) always wins; a real model-id env value means
96/// "enabled" regardless of this field.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct EmbedderSection {
99    #[serde(default = "default_embedder_id")]
100    pub model: String,
101    /// W3.1: persistent embeddings off-switch. Default true (enabled).
102    /// `#[serde(default = "default_true")]` keeps pre-W3 project.toml
103    /// files loading unchanged.
104    #[serde(default = "default_true")]
105    pub enabled: bool,
106    /// v1.0.0: use the warm embedder daemon for the `UserPromptSubmit`
107    /// hook. `false` ⇒ the hook never spawns/contacts a daemon and stays
108    /// on floored-FTS even on `embeddings` builds. Config equivalent of
109    /// `KIMETSU_EMBED_DAEMON=0`. `#[serde(default = "default_true")]`
110    /// keeps older configs loading with the daemon on.
111    #[serde(default = "default_true")]
112    pub daemon: bool,
113    /// v1.0.0: pre-warm the daemon at harness startup (via `kimetsu brain
114    /// warm`, wired to SessionStart). `false` ⇒ no startup spawn; the
115    /// daemon (if `daemon=true`) warms lazily on the first prompt instead.
116    #[serde(default = "default_true")]
117    pub warm_on_start: bool,
118    /// v1.0.0: cross-encoder reranker the warm daemon applies as the final
119    /// ranking stage. `"off"` (default), a curated fastembed reranker id
120    /// (`jina-reranker-v1-turbo-en`, `bge-reranker-base`,
121    /// `bge-reranker-v2-m3`, `jina-reranker-v2-base-multilingual`), a
122    /// benchmarked alias (`jina-reranker-v1-tiny-en`,
123    /// `ms-marco-tinybert-l-2-v2`, `ms-marco-minilm-l-4-v2`), or any
124    /// HuggingFace `org/repo` with an ONNX export.
125    ///
126    /// Default `ms-marco-tinybert-l-2-v2`, chosen with `kimetsu brain
127    /// bench` on the 100-case real-memory dataset: paired with the
128    /// `jina-v2-base-code` embedder it lands within noise of the best
129    /// quality (MRR 0.938 vs 0.953 top) at ~43ms per rerank — far inside
130    /// the hook's 300ms budget. On slower machines a miss degrades
131    /// gracefully to floored-FTS for that turn. `"off"` disables
132    /// reranking. Validate changes on your own corpus with
133    /// `kimetsu brain bench` / `kimetsu brain eval`.
134    #[serde(default = "default_reranker_id")]
135    pub reranker: String,
136}
137
138fn default_embedder_id() -> String {
139    "jina-v2-base-code".to_string()
140}
141
142fn default_reranker_id() -> String {
143    "ms-marco-tinybert-l-2-v2".to_string()
144}
145
146fn default_true() -> bool {
147    true
148}
149
150impl Default for EmbedderSection {
151    fn default() -> Self {
152        Self {
153            model: default_embedder_id(),
154            enabled: default_true(),
155            daemon: default_true(),
156            warm_on_start: default_true(),
157            reranker: default_reranker_id(),
158        }
159    }
160}
161
162/// v0.8.5: automatic memory harvesting. When `auto_harvest` is on, the
163/// proactive PostToolUse hook and the Stop hook emit a `[kimetsu-harvest]`
164/// cue at high-signal moments (a failed-then-fixed command, or a
165/// non-trivial session that recorded nothing) telling the agent to
166/// dispatch the `kimetsu-memory-harvester` subagent. Set it false to
167/// silence those cues.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct LearningSection {
170    #[serde(default = "default_auto_harvest")]
171    pub auto_harvest: bool,
172    /// v1.5: store the raw retrieval query in local `context.served`
173    /// telemetry so the self-tuning loop can build a personal eval set.
174    /// Data never leaves the machine and is never exported. Set false to
175    /// keep only the query hash (the pre-v1.5 behavior). Default true so
176    /// new installs gain the eval-set signal immediately on upgrade;
177    /// `#[serde(default = "default_true")]` keeps pre-v1.5 project.toml
178    /// files loading cleanly (they get store_queries = true).
179    #[serde(default = "default_true")]
180    pub store_queries: bool,
181    /// Opt-in credentialed SessionEnd distiller (configured by the install
182    /// wizard). Disabled by default; `#[serde(default)]` keeps older
183    /// project.toml files loading.
184    #[serde(default)]
185    pub distiller: DistillerSection,
186}
187
188fn default_auto_harvest() -> bool {
189    true
190}
191
192impl Default for LearningSection {
193    fn default() -> Self {
194        Self {
195            auto_harvest: default_auto_harvest(),
196            store_queries: default_true(),
197            distiller: DistillerSection::default(),
198        }
199    }
200}
201
202/// Credentialed SessionEnd distiller config. Secret values (the API key,
203/// optional base URL) live in `.env` under the env-var names below; only
204/// non-secret selection lives here. `provider` is `anthropic`, `openai`, or
205/// `bedrock`.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct DistillerSection {
208    #[serde(default)]
209    pub enabled: bool,
210    #[serde(default = "default_distiller_provider")]
211    pub provider: String,
212    #[serde(default = "default_distiller_model")]
213    pub model: String,
214    #[serde(default = "default_distiller_api_key_env")]
215    pub api_key_env: String,
216    #[serde(default = "default_distiller_base_url_env")]
217    pub base_url_env: String,
218    /// AWS Bedrock distiller: literal region. Takes precedence over
219    /// `region_env`. `#[serde(default)]` keeps existing config loading.
220    #[serde(default)]
221    pub region: Option<String>,
222    /// AWS Bedrock distiller: env-var name that holds the region.
223    /// Defaults to `"AWS_REGION"`. `#[serde(default)]` keeps existing
224    /// config loading cleanly.
225    #[serde(default = "default_distiller_region_env")]
226    pub region_env: String,
227}
228
229fn default_distiller_provider() -> String {
230    "anthropic".to_string()
231}
232fn default_distiller_model() -> String {
233    "claude-haiku-4-5".to_string()
234}
235fn default_distiller_api_key_env() -> String {
236    "ANTHROPIC_API_KEY".to_string()
237}
238fn default_distiller_base_url_env() -> String {
239    "ANTHROPIC_BASE_URL".to_string()
240}
241
242fn default_distiller_region_env() -> String {
243    "AWS_REGION".to_string()
244}
245
246impl Default for DistillerSection {
247    fn default() -> Self {
248        Self {
249            enabled: false,
250            provider: default_distiller_provider(),
251            model: default_distiller_model(),
252            api_key_env: default_distiller_api_key_env(),
253            base_url_env: default_distiller_base_url_env(),
254            region: None,
255            region_env: default_distiller_region_env(),
256        }
257    }
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct ModelSection {
262    pub provider: String,
263    pub model: String,
264    pub api_key_env: String,
265    pub max_output_tokens: u32,
266    pub temperature: f32,
267    pub request_timeout_secs: u64,
268    /// AWS Bedrock: literal region (e.g. `us-east-1`). Takes precedence over
269    /// `region_env`. `#[serde(default)]` keeps existing project.toml loading.
270    #[serde(default)]
271    pub region: Option<String>,
272    /// AWS Bedrock: env-var name that holds the region. Defaults to
273    /// `"AWS_REGION"` via `default_region_env()`. Consulted only when
274    /// `region` is `None`. `#[serde(default)]` keeps existing project.toml
275    /// loading cleanly.
276    #[serde(default = "default_region_env")]
277    pub region_env: String,
278    /// v1.5: override the built-in $/MTok price table for the ROI ledger.
279    /// When set, this value is used for USD conversion instead of the
280    /// approximate built-in table.  Useful for private-endpoint pricing or
281    /// non-standard model deployments.  `#[serde(default)]` keeps all
282    /// pre-v1.5 project.toml files loading cleanly (they get `None`).
283    #[serde(default)]
284    pub price_per_mtok: Option<f64>,
285}
286
287fn default_region_env() -> String {
288    "AWS_REGION".to_string()
289}
290
291impl Default for ModelSection {
292    fn default() -> Self {
293        Self {
294            provider: "anthropic".to_string(),
295            model: "claude-opus-4-7".to_string(),
296            api_key_env: "ANTHROPIC_API_KEY".to_string(),
297            max_output_tokens: 8192,
298            temperature: 0.2,
299            request_timeout_secs: 120,
300            region: None,
301            region_env: default_region_env(),
302            price_per_mtok: None,
303        }
304    }
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct BrokerSection {
309    /// Flat per-stage budget (tokens). Used as a fallback when
310    /// `task_size == 0` (broker disabled or task-size signal unavailable)
311    /// and as the compat default for pre-F3 project.toml files.
312    /// For live runs the adaptive budget (`adaptive_budget`) supersedes this.
313    pub default_budget_tokens: u32,
314    pub weights: BrokerWeights,
315    /// D1f: hard cap on capsules rendered into a model prompt. The
316    /// broker may surface more capsules than this (up to the token
317    /// budget), but the pipeline render step truncates to this cap so
318    /// a tighter, higher-precision capsule set isn't silently padded
319    /// back to a larger number. 0 = disabled (budget-only limit).
320    ///
321    /// Default 8: lower than the old hard-coded 12 so precision from
322    /// D1e wins; operators can raise it per-project in project.toml.
323    /// `#[serde(default)]` keeps pre-D1f project.toml files loading.
324    #[serde(default = "default_max_capsules")]
325    pub max_capsules: usize,
326    /// D1e: absolute minimum cosine similarity between the query
327    /// embedding and a candidate embedding required for the candidate
328    /// to survive budgeting. When > 0.0, candidates whose cosine is
329    /// strictly below this threshold are dropped BEFORE the MMR pass
330    /// so a genuinely-irrelevant corpus hits the zero-capsule skipped
331    /// path more often. Inert on lean (NoopEmbedder) builds because
332    /// there is no query embedding to compare against.
333    ///
334    /// Default -1.0 = AUTO (v1.0.0): the right floor is MODEL-DEPENDENT,
335    /// because cosine scales differ per embedder. bge-family cosines for
336    /// related pairs sit well above ~0.5 with noise below ~0.4, so auto
337    /// resolves to 0.35 there. jina-v2 cosines run lower — the remote
338    /// benchmark showed a 0.35 floor KILLING relevant results outright
339    /// (MRR 0.90 → 0.77, recall@2 == recall@4) — and that model's own
340    /// precision already keeps noise low (~1.2 vs bge's ~4.0 capsules on
341    /// no-answer queries, floors off), so auto resolves to 0.0 (disabled)
342    /// for non-bge models. Set an explicit value to override auto in
343    /// either direction; 0.0 disables. `#[serde(default = …)]` keeps older
344    /// configs loading with auto.
345    #[serde(default = "default_min_semantic_score")]
346    pub min_semantic_score: f32,
347    /// v1.0.0: absolute *lexical* relevance floor for memory candidates,
348    /// expressed as the fraction of the query's IDF-weighted discriminating
349    /// power a memory must lexically cover to survive. Unlike
350    /// `min_semantic_score` (which needs a query embedding and is therefore
351    /// inert on the FTS-only `UserPromptSubmit` hook path), this floor works
352    /// on lexical retrieval — closing the gap where a broad conceptual query
353    /// ("what's the idea of the repo") surfaces unrelated memories that only
354    /// share a corpus-ubiquitous token like the project name.
355    ///
356    /// Mechanics: query tokens are stripped of stopwords; each remaining
357    /// token is IDF-weighted over the memory corpus (so the project name,
358    /// present in nearly every memory, contributes ~0). A memory is dropped
359    /// when the IDF-weighted share of the query it covers is below this floor
360    /// AND it has no semantic support. Repo-file/manifest capsules pass
361    /// through untouched (their FTS match on file content is itself the
362    /// relevance signal, and overview queries *want* the README).
363    ///
364    /// Default 0.5 = "must cover the more-discriminating half of the query."
365    /// 0.0 disables the floor. `#[serde(default = …)]` keeps older configs
366    /// loading with the floor active.
367    #[serde(default = "default_min_lexical_coverage")]
368    pub min_lexical_coverage: f32,
369    /// F3: floor for the adaptive per-stage brain budget. Small tasks
370    /// receive at least this many tokens so the brain is never starved.
371    /// `#[serde(default)]` keeps pre-F3 project.toml files loading cleanly.
372    #[serde(default = "default_budget_floor_tokens")]
373    pub budget_floor_tokens: u32,
374    /// F3: per-run global ceiling on brain-injected tokens across ALL
375    /// stages combined. Later stages receive only the remaining capacity
376    /// once earlier stages have been charged via the `RunRecallLedger`.
377    /// `#[serde(default)]` keeps pre-F3 project.toml files loading cleanly.
378    #[serde(default = "default_budget_run_cap_tokens")]
379    pub budget_run_cap_tokens: u32,
380    /// W3.2: persistent ambient-context off-switch. When false, the
381    /// workspace fingerprint (branch, recent files, dirty status) is not
382    /// collected or appended to the retrieval query. Precedence:
383    /// `KIMETSU_BRAIN_AMBIENT` env override > this field > default (true).
384    /// `#[serde(default = "default_true")]` keeps pre-W3 project.toml
385    /// files loading unchanged.
386    #[serde(default = "default_true")]
387    pub ambient: bool,
388    /// v1.5 (Story 2.1): render-time capsule compression. When true (default),
389    /// capsule summaries are compressed with [`compress_for_render`] before
390    /// being injected into hook stdout or MCP tool responses. Compression
391    /// strips `[tags: ...]` / `(context: ...)` annotations and caps at 3
392    /// sentences. Ranking is NEVER affected — compression runs only after
393    /// retrieval and reranking. Set false to inject full memory text (useful
394    /// for debugging or when summaries are already concise).
395    ///
396    /// `#[serde(default = "default_true")]` keeps pre-v1.5 project.toml files
397    /// loading cleanly (they get compression ON).
398    #[serde(default = "default_true")]
399    pub compress_capsules: bool,
400    /// v1.5 (Story 2.3): session-scoped cross-turn capsule dedupe. When true
401    /// (default), the `UserPromptSubmit` context hook skips capsules whose
402    /// `expansion_handle` was already injected earlier in the same session
403    /// (tracked via the proactive-state sidecar). A soft policy: skipping only
404    /// happens when at least one NEW capsule remains — if dedupe would empty
405    /// the injection entirely, all capsules are injected anyway (a repeated
406    /// top memory may still be the right context). Set false to disable
407    /// session dedupe and always inject the full ranked set.
408    ///
409    /// `#[serde(default = "default_true")]` keeps pre-v1.5 project.toml files
410    /// loading cleanly (they get session dedupe ON).
411    #[serde(default = "default_true")]
412    pub session_dedupe: bool,
413}
414
415fn default_max_capsules() -> usize {
416    8
417}
418
419fn default_min_semantic_score() -> f32 {
420    -1.0
421}
422
423fn default_min_lexical_coverage() -> f32 {
424    0.5
425}
426
427fn default_budget_floor_tokens() -> u32 {
428    1500
429}
430
431fn default_budget_run_cap_tokens() -> u32 {
432    8000
433}
434
435impl Default for BrokerSection {
436    fn default() -> Self {
437        Self {
438            default_budget_tokens: 6000,
439            weights: BrokerWeights::default(),
440            max_capsules: default_max_capsules(),
441            min_semantic_score: default_min_semantic_score(),
442            min_lexical_coverage: default_min_lexical_coverage(),
443            budget_floor_tokens: default_budget_floor_tokens(),
444            budget_run_cap_tokens: default_budget_run_cap_tokens(),
445            ambient: default_true(),
446            compress_capsules: default_true(),
447            session_dedupe: default_true(),
448        }
449    }
450}
451
452/// F3: compute the adaptive per-stage brain budget given a task-size signal.
453///
454/// **Task-size signal** (defined): `task_size = estimate_tokens(task_text) +
455/// estimate_tokens(localized_file_context)`, where `estimate_tokens` uses the
456/// same heuristic as the rest of the pipeline: `(whitespace_words * 1.33).ceil()`.
457/// Localized-file context is the rendered list of paths surfaced before the
458/// first implementation attempt.
459///
460/// **Scaling**: `floor + k * sqrt(task_size)` clamped to `[floor, run_cap]`.
461/// sqrt is chosen because it grows slower than linear — doubling task_size
462/// grows the budget by only ~41%, and a 5× task grows it by only ~124%
463/// (well under 2×).
464///
465/// **Constant k**: chosen so a "typical" task (task_size ≈ 200 tokens, e.g.
466/// a concise one-paragraph task + a handful of file paths) lands near
467/// today's default 6000 tokens, avoiding a behavior cliff on upgrade.
468///   k = (6000 - 1500) / sqrt(200) ≈ 318.2
469///
470/// **Fallback**: when `task_size == 0` (broker disabled, size signal
471/// unavailable, or called pre-retrieval) returns `floor` — callers should
472/// use `default_budget_tokens` instead in those paths.
473///
474/// **Per-run cap**: the caller is responsible for computing
475/// `remaining = run_cap.saturating_sub(ledger.injected_tokens())` and passing
476/// `min(adaptive_budget(...), remaining)` as the stage's `budget_tokens`.
477pub fn adaptive_budget(task_size: u32, floor: u32, run_cap: u32) -> u32 {
478    if task_size == 0 {
479        return floor;
480    }
481    // k ≈ 318.2 so that adaptive_budget(200, 1500, 8000) ≈ 6000.
482    // We scale k by 10 and work in integer arithmetic to avoid f64 in hot path.
483    const K_SCALED: u32 = 3182; // k * 10
484    let sqrt_part = (task_size as f64).sqrt();
485    let budget_f = floor as f64 + (K_SCALED as f64 / 10.0) * sqrt_part;
486    let budget = budget_f.round() as u32;
487    budget.clamp(floor, run_cap)
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct BrokerWeights {
492    pub relevance: f32,
493    pub confidence: f32,
494    pub freshness: f32,
495    pub scope: f32,
496    pub localization: Option<StageWeights>,
497    pub patch_plan: Option<StageWeights>,
498    pub verification: Option<StageWeights>,
499    pub review: Option<StageWeights>,
500    /// v0.5.1: half-life (in days) for the usefulness-decay
501    /// multiplier. A memory's effective usefulness contribution
502    /// decays as `exp(-ln(2) * age_days / half_life)` where age is
503    /// measured from `last_useful_at` if present, else
504    /// `created_at`. 30 days = a 6-month-old useful memory ends
505    /// up at ~1.5% of its original weight; tune lower for faster-
506    /// changing repos, higher for slow-evolving ones.
507    ///
508    /// `#[serde(default)]` keeps pre-v0.5.1 project.toml files
509    /// loading cleanly — they get the 30-day default.
510    #[serde(default = "default_decay_half_life_days")]
511    pub decay_half_life_days: f32,
512}
513
514fn default_decay_half_life_days() -> f32 {
515    30.0
516}
517
518impl Default for BrokerWeights {
519    fn default() -> Self {
520        Self {
521            relevance: 0.50,
522            confidence: 0.20,
523            freshness: 0.20,
524            scope: 0.10,
525            localization: Some(StageWeights {
526                relevance: 0.70,
527                confidence: 0.10,
528                freshness: 0.10,
529                scope: 0.10,
530            }),
531            patch_plan: Some(StageWeights {
532                relevance: 0.40,
533                confidence: 0.30,
534                freshness: 0.10,
535                scope: 0.20,
536            }),
537            verification: Some(StageWeights {
538                relevance: 0.40,
539                confidence: 0.10,
540                freshness: 0.40,
541                scope: 0.10,
542            }),
543            review: Some(StageWeights {
544                relevance: 0.50,
545                confidence: 0.20,
546                freshness: 0.20,
547                scope: 0.10,
548            }),
549            decay_half_life_days: default_decay_half_life_days(),
550        }
551    }
552}
553
554#[derive(Debug, Clone, Serialize, Deserialize)]
555pub struct StageWeights {
556    pub relevance: f32,
557    pub confidence: f32,
558    pub freshness: f32,
559    pub scope: f32,
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct ShellSection {
564    pub default_timeout_secs: u64,
565    pub max_timeout_secs: u64,
566    pub env_allowlist_extra: Vec<String>,
567    pub redact_secrets: bool,
568}
569
570impl Default for ShellSection {
571    fn default() -> Self {
572        Self {
573            default_timeout_secs: 60,
574            max_timeout_secs: 600,
575            env_allowlist_extra: vec!["RUSTFLAGS".to_string(), "CARGO_HOME".to_string()],
576            redact_secrets: true,
577        }
578    }
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct IngestionSection {
583    pub max_file_bytes: u64,
584    pub extra_skip_dirs: Vec<String>,
585    pub max_total_files: u64,
586    /// v1.0: enable/disable the per-add conflict-detection scan.
587    ///
588    /// Default true. Set to false (or set env `KIMETSU_DETECT_CONFLICTS=0`)
589    /// to skip the cosine-similarity conflict scan at add time — useful when
590    /// bulk-seeding a brain where the O(N²) scan would be prohibitively slow.
591    /// Review `kimetsu brain memory conflicts` afterwards to catch any
592    /// contradictions.
593    ///
594    /// Precedence: `KIMETSU_DETECT_CONFLICTS` env > this field > default.
595    #[serde(default = "default_true")]
596    pub detect_conflicts: bool,
597}
598
599impl Default for IngestionSection {
600    fn default() -> Self {
601        Self {
602            max_file_bytes: 524_288,
603            extra_skip_dirs: Vec::new(),
604            max_total_files: 50_000,
605            detect_conflicts: true,
606        }
607    }
608}
609
610#[derive(Debug, Clone, Serialize, Deserialize)]
611pub struct RunSection {
612    pub max_total_tool_calls: u32,
613    pub max_total_model_turns: u32,
614    pub max_total_cost_usd: f32,
615}
616
617impl Default for RunSection {
618    fn default() -> Self {
619        // `max_total_cost_usd` is treated as advisory under subscription-based
620        // providers (e.g. Claude Code OAuth). The agent loop still enforces it
621        // when it does fire, but the default is set high enough that it
622        // functions as a runaway-prevention safety net rather than a per-run
623        // budget. Tighten in `project.toml` when running against a metered
624        // provider.
625        Self {
626            max_total_tool_calls: 60,
627            max_total_model_turns: 30,
628            max_total_cost_usd: 250.0,
629        }
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    /// A pre-v0.8 project.toml has no `[embedder]` table. The
638    /// `#[serde(default)]` on the field must keep it loading cleanly,
639    /// defaulting to the lean English model.
640    #[test]
641    fn pre_v0_8_config_without_embedder_loads_with_default() {
642        let toml = r#"
643[kimetsu]
644project_id = "demo"
645schema_version = 7
646
647[model]
648provider = "anthropic"
649model = "claude-opus-4-7"
650api_key_env = "ANTHROPIC_API_KEY"
651max_output_tokens = 8192
652temperature = 0.2
653request_timeout_secs = 120
654
655[broker]
656default_budget_tokens = 6000
657
658[broker.weights]
659relevance = 0.5
660confidence = 0.2
661freshness = 0.2
662scope = 0.1
663
664[shell]
665default_timeout_secs = 60
666max_timeout_secs = 600
667env_allowlist_extra = []
668redact_secrets = true
669
670[ingestion]
671max_file_bytes = 524288
672extra_skip_dirs = []
673max_total_files = 50000
674
675[run]
676max_total_tool_calls = 60
677max_total_model_turns = 30
678max_total_cost_usd = 250.0
679"#;
680        let config = ProjectConfig::from_toml(toml).expect("pre-v0.8 toml must load");
681        assert_eq!(config.embedder.model, "jina-v2-base-code");
682        // A pre-v0.8.5 toml has no [learning] section — auto-harvest
683        // defaults on so existing installs gain the behavior on upgrade.
684        assert!(config.learning.auto_harvest);
685        // A pre-distiller toml has no [learning.distiller] — defaults to off,
686        // anthropic, claude-haiku-4-5.
687        assert!(!config.learning.distiller.enabled);
688        assert_eq!(config.learning.distiller.provider, "anthropic");
689        assert_eq!(config.learning.distiller.model, "claude-haiku-4-5");
690        assert_eq!(config.learning.distiller.api_key_env, "ANTHROPIC_API_KEY");
691        assert_eq!(config.learning.distiller.base_url_env, "ANTHROPIC_BASE_URL");
692        // D1e/D1f: pre-D1 configs without max_capsules / min_semantic_score
693        // must load cleanly and receive the safe defaults.
694        assert_eq!(config.broker.max_capsules, 8);
695        // v1.0.0: the semantic floor is ON by default (was 0.0/disabled) now
696        // that the warm daemon serves semantic retrieval to every prompt.
697        assert_eq!(config.broker.min_semantic_score, -1.0, "auto sentinel");
698        // v1.0.0: a config without min_lexical_coverage loads with the floor
699        // active at its default (0.5), so existing installs gain the relevance
700        // gate on upgrade.
701        assert_eq!(config.broker.min_lexical_coverage, 0.5);
702        // F3: pre-F3 configs without budget_floor_tokens / budget_run_cap_tokens
703        // must load cleanly and receive the safe defaults.
704        assert_eq!(config.broker.budget_floor_tokens, 1500);
705        assert_eq!(config.broker.budget_run_cap_tokens, 8000);
706        // W3: pre-W3 configs without the new off-switch fields must load
707        // cleanly and default to enabled (true) for all three features.
708        assert!(
709            config.embedder.enabled,
710            "W3.1: embedder.enabled must default to true"
711        );
712        assert!(
713            config.broker.ambient,
714            "W3.2: broker.ambient must default to true"
715        );
716        assert!(
717            config.kimetsu.use_user_brain,
718            "W3.3: kimetsu.use_user_brain must default to true"
719        );
720        // v1.0.0: daemon + warm_on_start default ON so existing installs get
721        // the warm-daemon path on upgrade.
722        assert!(
723            config.embedder.daemon,
724            "embedder.daemon must default to true"
725        );
726        assert!(
727            config.embedder.warm_on_start,
728            "embedder.warm_on_start must default to true"
729        );
730        // v1.0.0: reranker defaults to jina-reranker-v1-turbo-en so existing
731        // v1.0.0: a config without mcp_write_tools loads with local write
732        // tools ENABLED, so the record-a-lesson workflow the brain itself
733        // prescribes works out of the box on upgrade.
734        assert!(
735            config.kimetsu.mcp_write_tools,
736            "kimetsu.mcp_write_tools must default to true"
737        );
738        // v1.0.0: jina-tiny + pool 6 measured as fitting the hook's 300ms
739        // budget on real memories with the best benchmark quality, so the
740        // reranker is ON by default.
741        assert_eq!(
742            config.embedder.reranker, "ms-marco-tinybert-l-2-v2",
743            "embedder.reranker must default to ms-marco-tinybert-l-2-v2"
744        );
745        // v1.5: a pre-v1.5 project.toml has no learning.store_queries —
746        // defaults to true so existing installs gain the eval-set signal
747        // on upgrade without any config change.
748        assert!(
749            config.learning.store_queries,
750            "learning.store_queries must default to true"
751        );
752        // v1.5 (Story 2.1): a pre-v1.5 project.toml without broker.compress_capsules
753        // must load cleanly and default to true (compression ON).
754        assert!(
755            config.broker.compress_capsules,
756            "broker.compress_capsules must default to true"
757        );
758        // v1.5 (Story 2.3): a pre-v1.5 project.toml without broker.session_dedupe
759        // must load cleanly and default to true (dedupe ON).
760        assert!(
761            config.broker.session_dedupe,
762            "broker.session_dedupe must default to true"
763        );
764    }
765
766    /// A1: default_for_project must use KIMETSU_CONFIG_VERSION (the
767    /// project.toml format version), NOT KIMETSU_SCHEMA_VERSION (the brain.db
768    /// schema). The two constants are intentionally decoupled so a DB-schema
769    /// bump does not force every project.toml to be rewritten.
770    #[test]
771    fn default_config_uses_config_version_not_schema_version() {
772        let cfg = ProjectConfig::default_for_project("p1");
773        assert_eq!(cfg.kimetsu.schema_version, crate::KIMETSU_CONFIG_VERSION);
774    }
775
776    /// `model set` writes the whole config back via `to_toml`; a
777    /// round-trip must preserve the chosen embedder (and other sections).
778    #[test]
779    fn embedder_survives_toml_round_trip() {
780        let mut config = ProjectConfig::default_for_project("demo");
781        config.embedder.model = "bge-m3".to_string();
782        let serialized = config.to_toml().expect("serialize");
783        let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
784        assert_eq!(reloaded.embedder.model, "bge-m3");
785        assert_eq!(reloaded.broker.default_budget_tokens, 6000);
786        assert_eq!(reloaded.kimetsu.project_id, "demo");
787        // F3 fields survive round-trip.
788        assert_eq!(reloaded.broker.budget_floor_tokens, 1500);
789        assert_eq!(reloaded.broker.budget_run_cap_tokens, 8000);
790        // W3 off-switch fields survive round-trip.
791        assert!(reloaded.embedder.enabled);
792        assert!(reloaded.broker.ambient);
793        assert!(reloaded.kimetsu.use_user_brain);
794    }
795
796    /// W3: the three new off-switch fields can be set to `false` in
797    /// project.toml and round-trip cleanly.
798    #[test]
799    fn w3_off_switch_fields_round_trip_as_false() {
800        let mut config = ProjectConfig::default_for_project("demo");
801        config.embedder.enabled = false;
802        config.broker.ambient = false;
803        config.kimetsu.use_user_brain = false;
804        let serialized = config.to_toml().expect("serialize");
805        let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
806        assert!(
807            !reloaded.embedder.enabled,
808            "embedder.enabled must survive as false"
809        );
810        assert!(
811            !reloaded.broker.ambient,
812            "broker.ambient must survive as false"
813        );
814        assert!(
815            !reloaded.kimetsu.use_user_brain,
816            "kimetsu.use_user_brain must survive as false"
817        );
818        // Unrelated fields unaffected.
819        assert_eq!(reloaded.kimetsu.project_id, "demo");
820    }
821
822    // ── F3: adaptive_budget unit tests ────────────────────────────────────
823
824    /// F3-budget-1: floor is returned when task_size == 0.
825    #[test]
826    fn f3_adaptive_budget_zero_size_returns_floor() {
827        assert_eq!(
828            super::adaptive_budget(0, 1500, 8000),
829            1500,
830            "task_size=0 must return floor"
831        );
832    }
833
834    /// F3-budget-2: run_cap is returned when task_size is enormous (very large).
835    #[test]
836    fn f3_adaptive_budget_huge_size_clamped_to_run_cap() {
837        let result = super::adaptive_budget(1_000_000, 1500, 8000);
838        assert_eq!(result, 8000, "huge task_size must be clamped to run_cap");
839    }
840
841    /// F3-budget-3: budget grows SUBLINEARLY — adaptive_budget(5*T) < 2 * adaptive_budget(T).
842    ///
843    /// With sqrt scaling: budget(5*T) / budget(T) = (floor + k*sqrt(5T)) / (floor + k*sqrt(T))
844    /// < sqrt(5) ≈ 2.236 for large T, but also < 2 for T in the practical range
845    /// because the floor term dominates at small sizes and sqrt(5) dominates at large
846    /// sizes. Specifically for T=200: budget(200)≈6000, budget(1000)≈8000 (capped) → ratio < 2.
847    /// For T=50 (below cap): budget(50)≈3751, budget(250)≈6534 → ratio ≈ 1.74 < 2. ✓
848    #[test]
849    fn f3_adaptive_budget_is_sublinear() {
850        let floor = 1500u32;
851        let run_cap = 16_000u32; // raised cap for this test so neither hits the ceiling
852
853        // T0 = 200 tokens (concise task), 5*T0 = 1000 tokens (verbose task)
854        let t0 = 200u32;
855        let b_t0 = super::adaptive_budget(t0, floor, run_cap);
856        let b_5t0 = super::adaptive_budget(5 * t0, floor, run_cap);
857
858        assert!(
859            b_5t0 < 2 * b_t0,
860            "sublinear guarantee: adaptive_budget(5*T)={b_5t0} must be < 2*adaptive_budget(T)={} (T={t0})",
861            2 * b_t0
862        );
863        assert!(
864            b_5t0 > b_t0,
865            "budget must still grow: adaptive_budget(5*T)={b_5t0} > adaptive_budget(T)={b_t0}"
866        );
867    }
868
869    /// F3-budget-4: a typical task (task_size ≈ 200) lands near the historical
870    /// default of 6000 tokens, avoiding a behavior cliff on upgrade.
871    #[test]
872    fn f3_adaptive_budget_typical_task_near_historical_default() {
873        let budget = super::adaptive_budget(200, 1500, 8000);
874        // k = 318.2 → floor + k*sqrt(200) = 1500 + 318.2*14.14 ≈ 5999
875        // Allow ±300 to tolerate rounding.
876        assert!(
877            (5700..=8000).contains(&budget),
878            "typical task budget expected near 6000, got {budget}"
879        );
880    }
881
882    /// F3-budget-5: floor is always respected — even small tasks get at least floor.
883    #[test]
884    fn f3_adaptive_budget_respects_floor() {
885        for size in [1u32, 5, 10, 50] {
886            let b = super::adaptive_budget(size, 1500, 8000);
887            assert!(
888                b >= 1500,
889                "task_size={size}: budget={b} must be >= floor=1500"
890            );
891        }
892    }
893
894    /// F3-budget-6: run_cap is always respected — large tasks never exceed cap.
895    #[test]
896    fn f3_adaptive_budget_respects_run_cap() {
897        for size in [500u32, 1000, 5000, 100_000] {
898            let b = super::adaptive_budget(size, 1500, 8000);
899            assert!(
900                b <= 8000,
901                "task_size={size}: budget={b} must be <= run_cap=8000"
902            );
903        }
904    }
905
906    /// v1.5: a pre-v1.5 project.toml without `model.price_per_mtok` must
907    /// load cleanly and default to `None` (backward compatibility).
908    #[test]
909    fn pre_v1_5_config_without_price_per_mtok_loads_with_none() {
910        let toml = r#"
911[kimetsu]
912project_id = "demo"
913schema_version = 7
914
915[model]
916provider = "anthropic"
917model = "claude-sonnet-4-7"
918api_key_env = "ANTHROPIC_API_KEY"
919max_output_tokens = 8192
920temperature = 0.2
921request_timeout_secs = 120
922
923[broker]
924default_budget_tokens = 6000
925
926[broker.weights]
927relevance = 0.5
928confidence = 0.2
929freshness = 0.2
930scope = 0.1
931
932[shell]
933default_timeout_secs = 60
934max_timeout_secs = 600
935env_allowlist_extra = []
936redact_secrets = true
937
938[ingestion]
939max_file_bytes = 524288
940extra_skip_dirs = []
941max_total_files = 50000
942
943[run]
944max_total_tool_calls = 60
945max_total_model_turns = 30
946max_total_cost_usd = 250.0
947"#;
948        let config = ProjectConfig::from_toml(toml).expect("pre-v1.5 toml must load");
949        assert!(
950            config.model.price_per_mtok.is_none(),
951            "price_per_mtok must default to None when absent from project.toml"
952        );
953    }
954
955    /// v1.5 (Story 2.1+2.3): compress_capsules and session_dedupe survive a
956    /// round-trip through serialize → deserialize when set to false.
957    #[test]
958    fn broker_v1_5_fields_round_trip_as_false() {
959        let mut config = ProjectConfig::default_for_project("demo");
960        config.broker.compress_capsules = false;
961        config.broker.session_dedupe = false;
962        let serialized = config.to_toml().expect("serialize");
963        let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
964        assert!(
965            !reloaded.broker.compress_capsules,
966            "compress_capsules must survive as false"
967        );
968        assert!(
969            !reloaded.broker.session_dedupe,
970            "session_dedupe must survive as false"
971        );
972    }
973
974    /// v1.5: when `model.price_per_mtok` is set in project.toml it must
975    /// round-trip cleanly through serialize → deserialize.
976    #[test]
977    fn price_per_mtok_round_trips() {
978        let mut config = ProjectConfig::default_for_project("demo");
979        config.model.price_per_mtok = Some(7.5);
980        let serialized = config.to_toml().expect("serialize");
981        let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
982        assert_eq!(
983            reloaded.model.price_per_mtok,
984            Some(7.5),
985            "price_per_mtok must round-trip"
986        );
987    }
988}