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}