Skip to main content

zeph_config/
features.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::num::NonZeroUsize;
5
6use serde::{Deserialize, Serialize};
7
8use crate::defaults::{default_skill_paths, default_true};
9use crate::learning::LearningConfig;
10use crate::providers::ProviderName;
11use crate::security::TrustConfig;
12
13fn default_disambiguation_threshold() -> f32 {
14    0.20
15}
16
17fn default_rl_learning_rate() -> f32 {
18    0.01
19}
20
21fn default_rl_weight() -> f32 {
22    0.3
23}
24
25fn default_rl_persist_interval() -> u32 {
26    10
27}
28
29fn default_rl_warmup_updates() -> u32 {
30    50
31}
32
33fn default_min_injection_score() -> f32 {
34    0.20
35}
36
37fn default_cosine_weight() -> f32 {
38    0.7
39}
40
41fn default_hybrid_search() -> bool {
42    true
43}
44
45fn default_bm25_alpha() -> f32 {
46    0.7
47}
48
49fn default_max_active_skills() -> NonZeroUsize {
50    NonZeroUsize::new(5).expect("5 is non-zero")
51}
52
53fn default_index_watch() -> bool {
54    // Default off: watcher watches ALL files recursively and bypasses gitignore
55    // filtering at the OS level. Projects with large .local/ or target/ directories
56    // trigger continuous reindex loops, causing unbounded memory growth.
57    // Users must explicitly opt in with `[index] watch = true`.
58    false
59}
60
61fn default_index_search_enabled() -> bool {
62    true
63}
64
65fn default_index_max_chunks() -> usize {
66    12
67}
68
69fn default_index_concurrency() -> usize {
70    2
71}
72
73fn default_index_batch_size() -> usize {
74    32
75}
76
77fn default_index_memory_batch_size() -> usize {
78    32
79}
80
81fn default_index_max_file_bytes() -> usize {
82    512 * 1024
83}
84
85fn default_index_embed_concurrency() -> usize {
86    2
87}
88
89fn default_initial_pass_batch_delay_ms() -> u64 {
90    75
91}
92
93fn default_index_score_threshold() -> f32 {
94    0.25
95}
96
97fn default_index_budget_ratio() -> f32 {
98    0.40
99}
100
101fn default_index_repo_map_tokens() -> usize {
102    500
103}
104
105fn default_repo_map_ttl_secs() -> u64 {
106    300
107}
108
109fn default_vault_backend() -> VaultBackend {
110    VaultBackend::Env
111}
112
113/// Selects the vault backend used to resolve secrets at startup.
114#[non_exhaustive]
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
116#[serde(rename_all = "lowercase")]
117pub enum VaultBackend {
118    /// Resolve secrets from environment variables (default, zero-config).
119    #[default]
120    Env,
121    /// Resolve secrets from an age-encrypted vault file.
122    Age,
123    /// Resolve secrets from the OS keyring.
124    Keyring,
125}
126
127impl std::fmt::Display for VaultBackend {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        match self {
130            Self::Env => f.write_str("env"),
131            Self::Age => f.write_str("age"),
132            Self::Keyring => f.write_str("keyring"),
133        }
134    }
135}
136
137fn default_max_daily_cents() -> u32 {
138    0
139}
140
141fn default_otlp_endpoint() -> String {
142    "http://localhost:4317".into()
143}
144
145fn default_pid_file() -> String {
146    "~/.zeph/zeph.pid".into()
147}
148
149fn default_health_interval() -> u64 {
150    30
151}
152
153fn default_max_restart_backoff() -> u64 {
154    60
155}
156
157fn default_scheduler_tick_interval() -> u64 {
158    60
159}
160
161fn default_scheduler_max_tasks() -> usize {
162    100
163}
164
165fn default_scheduler_daemon_tick_secs() -> u64 {
166    60
167}
168
169fn default_scheduler_handler_timeout_secs() -> u64 {
170    300
171}
172
173fn default_scheduler_daemon_shutdown_grace_secs() -> u64 {
174    30
175}
176
177fn default_scheduler_daemon_pid_file() -> String {
178    // MINOR-4: dirs::state_dir() is None on macOS, so we use platform-specific fallbacks.
179    #[cfg(target_os = "macos")]
180    {
181        dirs::data_local_dir()
182            .map_or_else(
183                || std::path::PathBuf::from("~/.zeph/zeph.pid"),
184                |d| d.join("zeph").join("zeph.pid"),
185            )
186            .to_string_lossy()
187            .into_owned()
188    }
189    #[cfg(not(target_os = "macos"))]
190    {
191        dirs::state_dir()
192            .or_else(dirs::data_local_dir)
193            .map_or_else(
194                || std::path::PathBuf::from("~/.zeph/zeph.pid"),
195                |d| d.join("zeph").join("zeph.pid"),
196            )
197            .to_string_lossy()
198            .into_owned()
199    }
200}
201
202fn default_scheduler_daemon_log_file() -> String {
203    #[cfg(target_os = "macos")]
204    {
205        // macOS: ~/Library/Logs/zeph/zeph.log
206        dirs::cache_dir()
207            .map_or_else(
208                || std::path::PathBuf::from("~/.zeph/zeph.log"),
209                |d| d.join("zeph").join("zeph.log"),
210            )
211            .to_string_lossy()
212            .into_owned()
213    }
214    #[cfg(not(target_os = "macos"))]
215    {
216        dirs::state_dir()
217            .or_else(dirs::data_local_dir)
218            .map_or_else(
219                || std::path::PathBuf::from("~/.zeph/zeph.log"),
220                |d| d.join("zeph").join("zeph.log"),
221            )
222            .to_string_lossy()
223            .into_owned()
224    }
225}
226
227fn default_gateway_bind() -> String {
228    "127.0.0.1".into()
229}
230
231fn default_gateway_port() -> u16 {
232    8090
233}
234
235fn default_gateway_rate_limit() -> u32 {
236    120
237}
238
239fn default_gateway_max_body() -> usize {
240    1_048_576
241}
242
243fn default_gateway_webhook_send_timeout_secs() -> u64 {
244    5
245}
246
247/// Controls how skills are formatted in the system prompt.
248#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
249#[serde(rename_all = "lowercase")]
250#[non_exhaustive]
251pub enum SkillPromptMode {
252    Full,
253    Compact,
254    #[default]
255    Auto,
256}
257
258/// Skill discovery and matching configuration, nested under `[skills]` in TOML.
259///
260/// Controls where skills are loaded from, how they are ranked during retrieval,
261/// the RL re-ranking head, NL skill generation, and automated skill mining.
262///
263/// # Example (TOML)
264///
265/// ```toml
266/// [skills]
267/// paths = ["~/.config/zeph/skills"]
268/// max_active_skills = 5
269/// disambiguation_threshold = 0.20
270/// hybrid_search = true
271/// ```
272#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
273#[derive(Debug, Deserialize, Serialize)]
274pub struct SkillsConfig {
275    /// Directories to scan for `*.skill.md` / `SKILL.md` files.
276    #[serde(default = "default_skill_paths")]
277    pub paths: Vec<String>,
278    #[serde(default = "default_max_active_skills")]
279    pub max_active_skills: NonZeroUsize,
280    #[serde(default = "default_disambiguation_threshold")]
281    pub disambiguation_threshold: f32,
282    #[serde(default = "default_min_injection_score")]
283    pub min_injection_score: f32,
284    #[serde(default = "default_cosine_weight")]
285    pub cosine_weight: f32,
286    #[serde(default = "default_hybrid_search")]
287    pub hybrid_search: bool,
288    /// Blend weight for BM25 hybrid retrieval: `score = bm25_alpha * cosine_clamped + (1 - bm25_alpha) * bm25_norm`.
289    ///
290    /// Only used when `hybrid_search = true`. Valid range: `[0.0, 1.0]`. Values outside this
291    /// range are clamped at load time with a warning. Default: `0.7` (cosine-dominant).
292    #[serde(default = "default_bm25_alpha")]
293    pub bm25_alpha: f32,
294    #[serde(default)]
295    pub learning: LearningConfig,
296    #[serde(default)]
297    pub trust: TrustConfig,
298    #[serde(default)]
299    pub prompt_mode: SkillPromptMode,
300    /// Enable two-stage category-first skill matching (requires `category` set in SKILL.md).
301    /// Falls back to flat matching when no multi-skill categories are available.
302    #[serde(default)]
303    pub two_stage_matching: bool,
304    /// Warn when any two skills have cosine similarity ≥ this threshold.
305    /// Set to 0.0 (default) to disable the confusability check entirely.
306    #[serde(default)]
307    pub confusability_threshold: f32,
308
309    // --- SkillOrchestra: RL routing head ---
310    /// Enable RL routing head for skill re-ranking (disabled by default).
311    #[serde(default)]
312    pub rl_routing_enabled: bool,
313    /// Learning rate for REINFORCE weight updates.
314    #[serde(default = "default_rl_learning_rate")]
315    pub rl_learning_rate: f32,
316    /// Blend weight: `final_score = (1-rl_weight)*cosine + rl_weight*rl_score`.
317    #[serde(default = "default_rl_weight")]
318    pub rl_weight: f32,
319    /// Persist weights every N updates (0 = persist every update).
320    #[serde(default = "default_rl_persist_interval")]
321    pub rl_persist_interval: u32,
322    /// Skip RL blending for the first N updates (cold-start warmup).
323    #[serde(default = "default_rl_warmup_updates")]
324    pub rl_warmup_updates: u32,
325    /// Embedding dimension for the RL routing head.
326    /// Must match the output dimension of the configured embedding provider.
327    /// Defaults to `None` → 1536 (`text-embedding-3-small` output dimension).
328    #[serde(default)]
329    pub rl_embed_dim: Option<usize>,
330
331    // --- Query rewriting ---
332    /// Provider name for optional query rewriting before skill matching.
333    ///
334    /// When set to a non-empty provider name, the query is rewritten via a fast LLM call
335    /// (5 s timeout) before embedding. The rewritten query is used only for skill matching,
336    /// not for the conversation. When empty (default), query rewriting is disabled and the
337    /// raw user query is embedded directly — zero overhead.
338    #[serde(default)]
339    pub query_rewrite_provider: ProviderName,
340
341    // --- NL skill generation ---
342    /// Provider name for `/skill create` NL generation. Empty = primary provider.
343    #[serde(default)]
344    pub generation_provider: ProviderName,
345    /// Timeout in milliseconds for `/skill create` LLM generation. For `/skill create` this is
346    /// enforced as a single end-to-end budget covering the initial call and its retry. The
347    /// background promotion path (`GeneratorSkillWriter`) reuses the same value as a per-call
348    /// budget instead (via `SkillGenerator::with_generation_timeout_ms`), so a generate-with-retry
349    /// there may take up to 2x this value. Default: `60000` (60 s).
350    #[serde(default = "default_generation_timeout_ms")]
351    pub generation_timeout_ms: u64,
352    /// Directory where generated skills are written. Defaults to first entry in `paths`.
353    #[serde(default)]
354    pub generation_output_dir: Option<String>,
355    /// Skill mining configuration.
356    #[serde(default)]
357    pub mining: SkillMiningConfig,
358    /// External-feedback skill evaluator configuration (#3319).
359    #[serde(default)]
360    pub evaluation: SkillEvaluationConfig,
361    /// Proactive world-knowledge exploration configuration (#3320).
362    #[serde(default)]
363    pub proactive_exploration: ProactiveExplorationConfig,
364    /// Provider name for skill disambiguation LLM classification calls.
365    ///
366    /// When set, the named provider is used instead of the primary provider for
367    /// skill disambiguation. Useful to route disambiguation to a cheaper or faster
368    /// model. When empty (the default), the primary provider is used.
369    #[serde(default)]
370    pub disambiguate_provider: ProviderName,
371
372    /// Enable LLM-backed semantic SKILL.md compliance scan on `plugin add`.
373    ///
374    /// When `true`, the agent asks an LLM whether the skill's declared purpose is
375    /// consistent with its actual content. Non-compliant skills are rejected with a
376    /// user-facing error message. `PluginError::SemanticViolation` is used only by the
377    /// Stage-1 ephemeral path. Stage-1 regex scan always runs and is advisory regardless
378    /// of this setting.
379    ///
380    /// Default: `false`.
381    #[serde(default)]
382    pub semantic_scan: bool,
383
384    /// Provider name (from `[[llm.providers]]`) used for the semantic scan.
385    ///
386    /// When empty (the default), the primary/main provider is used.
387    #[serde(default)]
388    pub semantic_scan_provider: ProviderName,
389
390    /// Enable `GoSkills` group-structured skill injection.
391    ///
392    /// When `true`, the top-N matched skills are presented to the LLM as an
393    /// entry-point + support structure, improving multi-skill task execution.
394    /// Falls back to flat injection when no pair exceeds `support_similarity_threshold`.
395    ///
396    /// Default: `false`.
397    #[serde(default)]
398    pub group_structured: bool,
399
400    /// Inter-skill cosine similarity threshold for `GoSkills` grouping.
401    ///
402    /// A candidate skill becomes a support skill when its cosine similarity to the
403    /// entry point exceeds this value (strict `>`). Valid range: `[0.0, 1.0]`.
404    ///
405    /// Default: `0.50`.
406    #[serde(default = "default_support_similarity_threshold")]
407    pub support_similarity_threshold: f32,
408}
409
410fn default_generation_timeout_ms() -> u64 {
411    60_000
412}
413
414fn default_support_similarity_threshold() -> f32 {
415    0.50
416}
417
418// --- SkillEvaluationConfig defaults ---
419
420fn default_skill_quality_threshold() -> f32 {
421    0.60
422}
423
424fn default_weight_correctness() -> f32 {
425    0.50
426}
427
428fn default_weight_reusability() -> f32 {
429    0.25
430}
431
432fn default_weight_specificity() -> f32 {
433    0.25
434}
435
436fn default_eval_fail_open() -> bool {
437    true
438}
439
440fn default_skill_eval_timeout_ms() -> u64 {
441    15_000
442}
443
444/// External-feedback skill evaluator configuration, nested under `[skills.evaluation]` in TOML.
445///
446/// When `enabled = true`, generated SKILL.md files are scored by a critic LLM before being
447/// written to disk. Skills below `quality_threshold` are rejected.
448///
449/// # Weights
450///
451/// `weight_correctness + weight_reusability + weight_specificity` must equal `1.0 ± 1e-3`.
452/// Starting defaults (0.50 / 0.25 / 0.25) are intuition-based and will be tuned after
453/// real-world telemetry is collected.
454///
455/// # Example (TOML)
456///
457/// ```toml
458/// [skills.evaluation]
459/// enabled = true
460/// provider = "fast"
461/// quality_threshold = 0.60
462/// fail_open_on_error = true
463/// timeout_ms = 15000
464/// ```
465#[derive(Debug, Deserialize, Serialize)]
466pub struct SkillEvaluationConfig {
467    /// Enable the evaluator gate. Default: `false`.
468    #[serde(default)]
469    pub enabled: bool,
470    /// Provider name for the critic LLM. Empty = primary provider.
471    #[serde(default)]
472    pub provider: ProviderName,
473    /// Minimum composite score required to accept a generated skill. Default: `0.60`.
474    #[serde(default = "default_skill_quality_threshold")]
475    pub quality_threshold: f32,
476    /// Weight for `correctness` in the composite score. Default: `0.50`.
477    #[serde(default = "default_weight_correctness")]
478    pub weight_correctness: f32,
479    /// Weight for `reusability` in the composite score. Default: `0.25`.
480    #[serde(default = "default_weight_reusability")]
481    pub weight_reusability: f32,
482    /// Weight for `specificity` in the composite score. Default: `0.25`.
483    #[serde(default = "default_weight_specificity")]
484    pub weight_specificity: f32,
485    /// Fail-open policy: accept skill when the evaluator call fails. Default: `true`.
486    #[serde(default = "default_eval_fail_open")]
487    pub fail_open_on_error: bool,
488    /// Maximum wait for the critic LLM in milliseconds. Default: `15000`.
489    #[serde(default = "default_skill_eval_timeout_ms")]
490    pub timeout_ms: u64,
491}
492
493impl Default for SkillEvaluationConfig {
494    fn default() -> Self {
495        Self {
496            enabled: false,
497            provider: ProviderName::default(),
498            quality_threshold: default_skill_quality_threshold(),
499            weight_correctness: default_weight_correctness(),
500            weight_reusability: default_weight_reusability(),
501            weight_specificity: default_weight_specificity(),
502            fail_open_on_error: default_eval_fail_open(),
503            timeout_ms: default_skill_eval_timeout_ms(),
504        }
505    }
506}
507
508// --- ProactiveExplorationConfig defaults ---
509
510fn default_proactive_max_chars() -> usize {
511    8_000
512}
513
514fn default_proactive_timeout_ms() -> u64 {
515    30_000
516}
517
518/// Proactive world-knowledge exploration configuration, nested under `[skills.proactive_exploration]` in TOML.
519///
520/// When `enabled = true`, the agent inspects each incoming query for a recognisable domain
521/// keyword (rust, python, docker, etc.) and generates a SKILL.md for that domain if one
522/// does not already exist. The skill is written to `output_dir` and registered in the
523/// skill registry; it becomes visible to the matcher on the **next** turn (next-turn
524/// visibility is intentional — see codebase comment in `ProactiveExplorer`).
525///
526/// # Example (TOML)
527///
528/// ```toml
529/// [skills.proactive_exploration]
530/// enabled = true
531/// output_dir = "~/.config/zeph/skills/generated"
532/// provider = "fast"
533/// ```
534#[derive(Debug, Deserialize, Serialize)]
535pub struct ProactiveExplorationConfig {
536    /// Enable proactive exploration. Default: `false`.
537    #[serde(default)]
538    pub enabled: bool,
539    /// Provider name for skill generation. Empty = primary provider.
540    #[serde(default)]
541    pub provider: ProviderName,
542    /// Directory where generated skills are written. Defaults to first `skills.paths` entry.
543    #[serde(default)]
544    pub output_dir: Option<String>,
545    /// Maximum SKILL.md body size in characters. Default: `8000`.
546    #[serde(default = "default_proactive_max_chars")]
547    pub max_chars: usize,
548    /// Per-exploration timeout in milliseconds. Default: `30000`.
549    #[serde(default = "default_proactive_timeout_ms")]
550    pub timeout_ms: u64,
551    /// Domain names to skip exploration for (e.g. `["rust"]` to suppress auto-generation
552    /// if you maintain your own Rust skill). Default: `[]`.
553    #[serde(default)]
554    pub excluded_domains: Vec<String>,
555}
556
557impl Default for ProactiveExplorationConfig {
558    fn default() -> Self {
559        Self {
560            enabled: false,
561            provider: ProviderName::default(),
562            output_dir: None,
563            max_chars: default_proactive_max_chars(),
564            timeout_ms: default_proactive_timeout_ms(),
565            excluded_domains: Vec::new(),
566        }
567    }
568}
569
570fn default_max_repos_per_query() -> usize {
571    20
572}
573
574fn default_dedup_threshold() -> f32 {
575    0.85
576}
577
578fn default_rate_limit_rpm() -> u32 {
579    25
580}
581
582/// Configuration for the automated skill mining pipeline (`zeph-skills-miner` binary).
583#[derive(Debug, Deserialize, Serialize)]
584pub struct SkillMiningConfig {
585    /// GitHub search queries for repo discovery (e.g. "topic:cli-tool language:rust stars:>100").
586    #[serde(default)]
587    pub queries: Vec<String>,
588    /// Maximum repos to fetch per query (capped at 100 by GitHub API). Default: 20.
589    #[serde(default = "default_max_repos_per_query")]
590    pub max_repos_per_query: usize,
591    /// Cosine similarity threshold for dedup against existing skills. Default: 0.85.
592    #[serde(default = "default_dedup_threshold")]
593    pub dedup_threshold: f32,
594    /// Output directory for mined skills.
595    #[serde(default)]
596    pub output_dir: Option<String>,
597    /// Provider name for skill generation during mining. Empty = primary provider.
598    #[serde(default)]
599    pub generation_provider: ProviderName,
600    /// Provider name for embedding during dedup. Empty = primary provider.
601    #[serde(default)]
602    pub embedding_provider: ProviderName,
603    /// Maximum GitHub search requests per minute. Default: 25.
604    #[serde(default = "default_rate_limit_rpm")]
605    pub rate_limit_rpm: u32,
606    /// Timeout in milliseconds for each LLM skill generation call during mining. Default: `30000` (30 s).
607    #[serde(default = "default_mining_generation_timeout_ms")]
608    pub generation_timeout_ms: u64,
609}
610
611impl Default for SkillMiningConfig {
612    fn default() -> Self {
613        Self {
614            queries: Vec::new(),
615            max_repos_per_query: default_max_repos_per_query(),
616            dedup_threshold: default_dedup_threshold(),
617            output_dir: None,
618            generation_provider: ProviderName::default(),
619            embedding_provider: ProviderName::default(),
620            rate_limit_rpm: default_rate_limit_rpm(),
621            generation_timeout_ms: default_mining_generation_timeout_ms(),
622        }
623    }
624}
625
626fn default_mining_generation_timeout_ms() -> u64 {
627    30_000
628}
629
630/// Code indexing and repo-map configuration, nested under `[index]` in TOML.
631///
632/// When `enabled = true`, the agent indexes source files into Qdrant for semantic
633/// code search. The repo map is injected into the system prompt or served via
634/// `IndexMcpServer` tool calls when `mcp_enabled = true`.
635///
636/// # Example (TOML)
637///
638/// ```toml
639/// [index]
640/// enabled = true
641/// watch = false
642/// max_chunks = 12
643/// score_threshold = 0.25
644/// ```
645#[derive(Debug, Deserialize, Serialize)]
646#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
647pub struct IndexConfig {
648    /// Enable code indexing. Default: `false`.
649    #[serde(default)]
650    pub enabled: bool,
651    /// Enable semantic code search tool. Default: `true` (no-op when `enabled = false`).
652    #[serde(default = "default_index_search_enabled")]
653    pub search_enabled: bool,
654    #[serde(default = "default_index_watch")]
655    pub watch: bool,
656    #[serde(default = "default_index_max_chunks")]
657    pub max_chunks: usize,
658    #[serde(default = "default_index_score_threshold")]
659    pub score_threshold: f32,
660    #[serde(default = "default_index_budget_ratio")]
661    pub budget_ratio: f32,
662    #[serde(default = "default_index_repo_map_tokens")]
663    pub repo_map_tokens: usize,
664    #[serde(default = "default_repo_map_ttl_secs")]
665    pub repo_map_ttl_secs: u64,
666    /// Enable `IndexMcpServer` tools (`symbol_definition`, `find_text_references`, `call_graph`,
667    /// `module_summary`). When `true`, static repo-map injection is skipped and the LLM
668    /// uses on-demand tool calls instead.
669    #[serde(default)]
670    pub mcp_enabled: bool,
671    /// Root directory to index. When `None`, falls back to the current working directory at
672    /// startup. Relative paths are resolved relative to the process working directory.
673    #[serde(default)]
674    pub workspace_root: Option<std::path::PathBuf>,
675    /// Bounds concurrent CPU-bound chunk-parse (tree-sitter) dispatches via an internal
676    /// semaphore. Default: 2.
677    ///
678    /// Only reduces concurrency below whatever `embed_concurrency` separately admits into
679    /// flight via `buffer_unordered` in `index_batch` — has no effect when set >=
680    /// `embed_concurrency` (the shipped default for both is 2).
681    #[serde(default = "default_index_concurrency")]
682    pub concurrency: usize,
683    /// Delay in milliseconds inserted after each memory batch during the *initial* full-repo
684    /// indexing pass only (not applied to incremental single-file reindex via the file
685    /// watcher). Spreads CPU-bound chunk parsing over more wall-clock time so an interactive
686    /// agent turn isn't starved for OS threads on large workspaces. Default: 75.
687    #[serde(default = "default_initial_pass_batch_delay_ms")]
688    pub initial_pass_batch_delay_ms: u64,
689    /// Maximum number of new chunks to batch into a single Qdrant upsert per file. Default: 32.
690    #[serde(default = "default_index_batch_size")]
691    pub batch_size: usize,
692    /// Number of files to process per memory batch during initial indexing.
693    /// After each batch the stream is dropped and the executor yields to allow
694    /// the allocator to reclaim pages. Default: `32`.
695    #[serde(default = "default_index_memory_batch_size")]
696    pub memory_batch_size: usize,
697    /// Maximum file size in bytes to index. Files larger than this are skipped.
698    /// Protects against large generated files (e.g. lock files, minified JS).
699    /// Default: 512 KiB.
700    #[serde(default = "default_index_max_file_bytes")]
701    pub max_file_bytes: usize,
702    /// Name of a `[[llm.providers]]` entry to use exclusively for embedding calls during
703    /// indexing. A dedicated provider prevents the indexer from contending with the guardrail
704    /// at the API server level (rate limits, Ollama single-model lock). Falls back to the main
705    /// agent provider when `None`.
706    #[serde(default)]
707    pub embedding_provider: Option<ProviderName>,
708    /// Maximum parallel `embed_batch` calls during indexing (default: 2 to stay within provider
709    /// TPM limits).
710    #[serde(default = "default_index_embed_concurrency")]
711    pub embed_concurrency: usize,
712}
713
714impl Default for IndexConfig {
715    fn default() -> Self {
716        Self {
717            enabled: false,
718            search_enabled: default_index_search_enabled(),
719            watch: default_index_watch(),
720            max_chunks: default_index_max_chunks(),
721            score_threshold: default_index_score_threshold(),
722            budget_ratio: default_index_budget_ratio(),
723            repo_map_tokens: default_index_repo_map_tokens(),
724            repo_map_ttl_secs: default_repo_map_ttl_secs(),
725            mcp_enabled: false,
726            workspace_root: None,
727            concurrency: default_index_concurrency(),
728            initial_pass_batch_delay_ms: default_initial_pass_batch_delay_ms(),
729            batch_size: default_index_batch_size(),
730            memory_batch_size: default_index_memory_batch_size(),
731            max_file_bytes: default_index_max_file_bytes(),
732            embedding_provider: None,
733            embed_concurrency: default_index_embed_concurrency(),
734        }
735    }
736}
737
738/// Vault backend configuration, nested under `[vault]` in TOML.
739///
740/// Selects how API keys and secrets are resolved at startup.
741///
742/// # Example (TOML)
743///
744/// ```toml
745/// [vault]
746/// backend = "age"
747/// ```
748#[derive(Debug, Deserialize, Serialize)]
749pub struct VaultConfig {
750    /// Which backend resolves secrets. Default: [`VaultBackend::Env`].
751    #[serde(default = "default_vault_backend")]
752    pub backend: VaultBackend,
753}
754
755impl Default for VaultConfig {
756    fn default() -> Self {
757        Self {
758            backend: default_vault_backend(),
759        }
760    }
761}
762
763/// Cost tracking and budget configuration, nested under `[cost]` in TOML.
764///
765/// When `enabled = true`, token costs are accumulated per session and displayed in
766/// the TUI. When `max_daily_cents > 0`, the agent refuses new turns once the daily
767/// budget is exhausted.
768///
769/// # Example (TOML)
770///
771/// ```toml
772/// [cost]
773/// enabled = true
774/// max_daily_cents = 500  # $5.00 per day
775/// ```
776#[derive(Debug, Deserialize, Serialize)]
777pub struct CostConfig {
778    /// Track and display token costs. Default: `true`.
779    #[serde(default = "default_true")]
780    pub enabled: bool,
781    /// Daily spending cap in US cents (`0` = unlimited). Default: `0`.
782    #[serde(default = "default_max_daily_cents")]
783    pub max_daily_cents: u32,
784}
785
786impl Default for CostConfig {
787    fn default() -> Self {
788        Self {
789            enabled: true,
790            max_daily_cents: default_max_daily_cents(),
791        }
792    }
793}
794
795/// HTTP webhook gateway configuration, nested under `[gateway]` in TOML.
796///
797/// When `enabled = true`, an HTTP server accepts webhook payloads and injects them
798/// as user messages into the agent. Requires the `gateway` feature flag.
799///
800/// # Example (TOML)
801///
802/// ```toml
803/// [gateway]
804/// enabled = true
805/// bind = "127.0.0.1"
806/// port = 8090
807/// auth_token = "secret"
808/// rate_limit = 60
809/// max_body_size = 1048576
810/// webhook_send_timeout_secs = 5
811/// ```
812#[derive(Debug, Clone, Deserialize, Serialize)]
813pub struct GatewayConfig {
814    /// Enable the HTTP gateway. Default: `false`.
815    #[serde(default)]
816    pub enabled: bool,
817    /// IP address to bind the gateway to. Default: `"127.0.0.1"`.
818    #[serde(default = "default_gateway_bind")]
819    pub bind: String,
820    /// Port to listen on. Default: `8090`.
821    #[serde(default = "default_gateway_port")]
822    pub port: u16,
823    /// Bearer token for request authentication. When set, all requests must include
824    /// `Authorization: Bearer <token>`. Default: `None` (no auth).
825    #[serde(default)]
826    pub auth_token: Option<String>,
827    /// Maximum requests per minute. Must be `> 0`. Default: `120`.
828    #[serde(default = "default_gateway_rate_limit")]
829    pub rate_limit: u32,
830    /// Maximum request body size in bytes. Must be `<= 10 MiB`. Default: `1048576` (1 MiB).
831    #[serde(default = "default_gateway_max_body")]
832    pub max_body_size: usize,
833    /// Maximum seconds to wait for the agent to consume a webhook message before
834    /// returning `503 Service Unavailable`. Default: `5`.
835    #[serde(default = "default_gateway_webhook_send_timeout_secs")]
836    pub webhook_send_timeout_secs: u64,
837    /// CIDR ranges of trusted reverse proxies (e.g. `["10.0.0.0/8", "172.16.0.0/12"]`).
838    ///
839    /// When non-empty, the rate limiter applies the **rightmost-untrusted** algorithm on the
840    /// `X-Forwarded-For` header: it walks the header from right to left and picks the first
841    /// IP address that does NOT fall within any listed CIDR.  This is the correct algorithm
842    /// when your proxy chain always appends, never prepends, so the rightmost entry added by
843    /// the infrastructure is the one closest to your origin.
844    ///
845    /// Leave empty (the default) to use the raw TCP peer address for rate limiting, which is
846    /// correct for deployments without a reverse proxy.
847    ///
848    /// Security note: only list CIDRs you fully control.  Any IP in a trusted CIDR can forge
849    /// `X-Forwarded-For` and bypass per-IP rate limiting.
850    #[serde(default)]
851    pub trusted_proxy_cidrs: Vec<String>,
852}
853
854impl Default for GatewayConfig {
855    fn default() -> Self {
856        Self {
857            enabled: false,
858            bind: default_gateway_bind(),
859            port: default_gateway_port(),
860            auth_token: None,
861            rate_limit: default_gateway_rate_limit(),
862            max_body_size: default_gateway_max_body(),
863            webhook_send_timeout_secs: default_gateway_webhook_send_timeout_secs(),
864            trusted_proxy_cidrs: Vec::new(),
865        }
866    }
867}
868
869impl GatewayConfig {
870    /// Validate gateway configuration values.
871    ///
872    /// # Errors
873    ///
874    /// Returns an error string when:
875    /// - `webhook_send_timeout_secs` is `0` or exceeds `300`
876    /// - `max_body_size` exceeds `10 MiB` (`10485760` bytes)
877    /// - `rate_limit` is `0` (causes division-by-zero in the token-bucket rate limiter)
878    #[must_use = "validation result must be checked"]
879    pub fn validate(&self) -> Result<(), String> {
880        if self.webhook_send_timeout_secs == 0 || self.webhook_send_timeout_secs > 300 {
881            return Err("webhook_send_timeout_secs must be between 1 and 300".to_owned());
882        }
883        if self.max_body_size > 10 * 1024 * 1024 {
884            return Err("max_body_size must be <= 10485760 (10 MiB)".to_owned());
885        }
886        if self.rate_limit == 0 {
887            return Err("rate_limit must be > 0".to_owned());
888        }
889        Ok(())
890    }
891}
892
893/// Daemon / process supervisor configuration, nested under `[daemon]` in TOML.
894///
895/// When `enabled = true`, Zeph runs as a background process with automatic restart
896/// and health monitoring.
897///
898/// # Example (TOML)
899///
900/// ```toml
901/// [daemon]
902/// enabled = true
903/// pid_file = "~/.zeph/zeph.pid"
904/// health_interval_secs = 30
905/// ```
906#[derive(Debug, Clone, Deserialize, Serialize)]
907pub struct DaemonConfig {
908    /// Run Zeph as a background daemon. Default: `false`.
909    #[serde(default)]
910    pub enabled: bool,
911    /// Path to the PID file written at daemon startup. Default: `"~/.zeph/zeph.pid"`.
912    #[serde(default = "default_pid_file")]
913    pub pid_file: String,
914    /// Interval in seconds between health checks. Default: `30`.
915    #[serde(default = "default_health_interval")]
916    pub health_interval_secs: u64,
917    /// Maximum backoff in seconds between restart attempts. Default: `60`.
918    #[serde(default = "default_max_restart_backoff")]
919    pub max_restart_backoff_secs: u64,
920}
921
922impl Default for DaemonConfig {
923    fn default() -> Self {
924        Self {
925            enabled: false,
926            pid_file: default_pid_file(),
927            health_interval_secs: default_health_interval(),
928            max_restart_backoff_secs: default_max_restart_backoff(),
929        }
930    }
931}
932
933/// Daemon mode configuration for `zeph serve`, nested under `[scheduler.daemon]` in TOML.
934///
935/// Controls the behaviour of the background scheduler process started by `zeph serve`.
936/// The pid file **must be on a local filesystem**; NFS mounts may not provide reliable
937/// exclusive locking.
938///
939/// Log rotation requires `logrotate copytruncate` or a SIGHUP signal; the daemon does
940/// not rotate logs internally (append-only log file).
941///
942/// # Platform defaults
943///
944/// - **macOS**: pid `~/Library/Application Support/zeph/zeph.pid`,
945///   log `~/Library/Caches/zeph/zeph.log`
946/// - **Linux**: pid `$XDG_STATE_HOME/zeph/zeph.pid`,
947///   log `$XDG_STATE_HOME/zeph/zeph.log`
948///
949/// # Example (TOML)
950///
951/// ```toml
952/// [scheduler.daemon]
953/// pid_file  = "~/.local/state/zeph/zeph.pid"
954/// log_file  = "~/.local/state/zeph/zeph.log"
955/// catch_up  = true
956/// tick_secs = 60
957/// shutdown_grace_secs = 30
958/// ```
959#[derive(Debug, Clone, Deserialize, Serialize)]
960pub struct SchedulerDaemonConfig {
961    /// Path to the PID file. Must reside on a local filesystem for reliable locking.
962    #[serde(default = "default_scheduler_daemon_pid_file")]
963    pub pid_file: String,
964    /// Path to the daemon log file (append-only; rotated externally).
965    #[serde(default = "default_scheduler_daemon_log_file")]
966    pub log_file: String,
967    /// When `true`, fire overdue periodic tasks once on startup before entering the
968    /// regular tick loop. At most one missed occurrence per task is replayed.
969    #[serde(default = "crate::defaults::default_true")]
970    pub catch_up: bool,
971    /// Tick interval in seconds (clamped to `5..=3600`). Default: `60`.
972    #[serde(default = "default_scheduler_daemon_tick_secs")]
973    pub tick_secs: u64,
974    /// Graceful shutdown window in seconds: how long to wait for in-flight tasks
975    /// after a SIGTERM before forcing an exit. Default: `30`.
976    #[serde(default = "default_scheduler_daemon_shutdown_grace_secs")]
977    pub shutdown_grace_secs: u64,
978    /// Maximum seconds a task handler may run before being forcibly cancelled.
979    /// Default: `300`. Set to `0` to disable the timeout.
980    #[serde(default = "default_scheduler_handler_timeout_secs")]
981    pub handler_timeout_secs: u64,
982}
983
984impl Default for SchedulerDaemonConfig {
985    fn default() -> Self {
986        Self {
987            pid_file: default_scheduler_daemon_pid_file(),
988            log_file: default_scheduler_daemon_log_file(),
989            catch_up: true,
990            tick_secs: default_scheduler_daemon_tick_secs(),
991            shutdown_grace_secs: default_scheduler_daemon_shutdown_grace_secs(),
992            handler_timeout_secs: default_scheduler_handler_timeout_secs(),
993        }
994    }
995}
996
997/// RTW-A temporal re-entry defense configuration for the scheduler.
998///
999/// Controls the four RTW-A mechanisms that protect the scheduler tick boundary
1000/// from prompt-injection attacks originating from the database.
1001///
1002/// # Example (TOML)
1003///
1004/// ```toml
1005/// [scheduler.security]
1006/// enabled = true
1007/// injection_pattern_check = true
1008/// attenuate_after_external_read = true
1009/// ```
1010#[derive(Debug, Clone, Deserialize, Serialize)]
1011pub struct SchedulerSecurityConfig {
1012    /// Enable all RTW-A re-entry defense mechanisms. Default: `true`.
1013    #[serde(default = "default_true")]
1014    pub enabled: bool,
1015
1016    /// Mechanism 3: scan `task_data` for injection patterns before forwarding to the LLM.
1017    ///
1018    /// When enabled, prompts matching known injection markers are blocked and a
1019    /// `SchedulerError::PromptInjectionBlocked` is emitted.
1020    /// Default: `true`.
1021    #[serde(default = "default_true")]
1022    pub injection_pattern_check: bool,
1023
1024    /// Mechanism 4: suppress `custom_task_tx` prompt injection after an external-read tick.
1025    ///
1026    /// When enabled, any tick that includes an `UpdateCheck` (or future network-reading)
1027    /// handler will not forward custom task prompts to the agent loop for that tick.
1028    /// Default: `true`.
1029    #[serde(default = "default_true")]
1030    pub attenuate_after_external_read: bool,
1031}
1032
1033impl Default for SchedulerSecurityConfig {
1034    fn default() -> Self {
1035        Self {
1036            enabled: true,
1037            injection_pattern_check: true,
1038            attenuate_after_external_read: true,
1039        }
1040    }
1041}
1042
1043/// Cron-based task scheduler configuration, nested under `[scheduler]` in TOML.
1044///
1045/// When `enabled = true`, the scheduler runs periodic tasks on a cron schedule.
1046/// Requires the `scheduler` feature flag.
1047///
1048/// # Example (TOML)
1049///
1050/// ```toml
1051/// [scheduler]
1052/// enabled = true
1053/// tick_interval_secs = 60
1054/// max_tasks = 20
1055///
1056/// [[scheduler.tasks]]
1057/// name = "daily-summary"
1058/// cron = "0 9 * * *"
1059/// kind = "custom"
1060/// config = { prompt = "Summarize what was accomplished today." }
1061/// ```
1062#[derive(Debug, Clone, Deserialize, Serialize)]
1063pub struct SchedulerConfig {
1064    /// Enable the task scheduler. Default: `false`.
1065    #[serde(default)]
1066    pub enabled: bool,
1067    /// How often the scheduler checks for due tasks, in seconds. Default: `60`.
1068    #[serde(default = "default_scheduler_tick_interval")]
1069    pub tick_interval_secs: u64,
1070    /// Maximum number of scheduled tasks allowed. Default: `100`.
1071    #[serde(default = "default_scheduler_max_tasks")]
1072    pub max_tasks: usize,
1073    /// List of scheduled task definitions.
1074    #[serde(default)]
1075    pub tasks: Vec<ScheduledTaskConfig>,
1076    /// Daemon lifecycle settings used by `zeph serve` / `zeph stop` / `zeph status`.
1077    #[serde(default)]
1078    pub daemon: SchedulerDaemonConfig,
1079    /// RTW-A re-entry defense settings.
1080    #[serde(default)]
1081    pub security: SchedulerSecurityConfig,
1082}
1083
1084impl Default for SchedulerConfig {
1085    fn default() -> Self {
1086        Self {
1087            enabled: false,
1088            tick_interval_secs: default_scheduler_tick_interval(),
1089            max_tasks: default_scheduler_max_tasks(),
1090            tasks: Vec::new(),
1091            daemon: SchedulerDaemonConfig::default(),
1092            security: SchedulerSecurityConfig::default(),
1093        }
1094    }
1095}
1096
1097/// Task kind for scheduled tasks.
1098///
1099/// Known variants map to built-in handlers; `Custom` accommodates user-defined task types.
1100#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
1101#[serde(rename_all = "snake_case")]
1102#[non_exhaustive]
1103pub enum ScheduledTaskKind {
1104    MemoryCleanup,
1105    SkillRefresh,
1106    HealthCheck,
1107    UpdateCheck,
1108    Experiment,
1109    Custom(String),
1110}
1111
1112/// A single scheduled task entry, nested under `[[scheduler.tasks]]` in TOML.
1113///
1114/// Either `cron` (recurring) or `run_at` (one-shot ISO 8601 datetime) must be set.
1115#[derive(Debug, Clone, Deserialize, Serialize)]
1116pub struct ScheduledTaskConfig {
1117    /// Unique task name used in logs and the scheduler database.
1118    pub name: String,
1119    /// Cron expression for recurring tasks (e.g. `"0 9 * * *"` for daily at 09:00).
1120    #[serde(default, skip_serializing_if = "Option::is_none")]
1121    pub cron: Option<String>,
1122    /// One-shot ISO 8601 datetime for one-time tasks. Ignored when `cron` is set.
1123    #[serde(default, skip_serializing_if = "Option::is_none")]
1124    pub run_at: Option<String>,
1125    /// Determines which built-in handler executes this task.
1126    pub kind: ScheduledTaskKind,
1127    /// Arbitrary JSON configuration forwarded to the task handler.
1128    #[serde(default)]
1129    pub config: serde_json::Value,
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134    use super::*;
1135
1136    #[test]
1137    fn index_config_defaults() {
1138        let cfg = IndexConfig::default();
1139        assert!(!cfg.enabled);
1140        assert!(cfg.search_enabled);
1141        assert!(!cfg.watch);
1142        assert_eq!(cfg.concurrency, 2);
1143        assert_eq!(cfg.batch_size, 32);
1144        assert_eq!(cfg.initial_pass_batch_delay_ms, 75);
1145        assert!(cfg.workspace_root.is_none());
1146    }
1147
1148    #[test]
1149    fn index_config_serde_roundtrip_with_new_fields() {
1150        let toml = r#"
1151            enabled = true
1152            concurrency = 8
1153            batch_size = 16
1154            workspace_root = "/tmp/myproject"
1155        "#;
1156        let cfg: IndexConfig = toml::from_str(toml).unwrap();
1157        assert!(cfg.enabled);
1158        assert_eq!(cfg.concurrency, 8);
1159        assert_eq!(cfg.batch_size, 16);
1160        assert_eq!(
1161            cfg.workspace_root,
1162            Some(std::path::PathBuf::from("/tmp/myproject"))
1163        );
1164        // Re-serialize and deserialize
1165        let serialized = toml::to_string(&cfg).unwrap();
1166        let cfg2: IndexConfig = toml::from_str(&serialized).unwrap();
1167        assert_eq!(cfg2.concurrency, 8);
1168        assert_eq!(cfg2.batch_size, 16);
1169    }
1170
1171    #[test]
1172    fn index_config_backward_compat_old_toml_without_new_fields() {
1173        // Old config without workspace_root, concurrency, batch_size — must still parse
1174        // and use defaults for the missing fields.
1175        let toml = "
1176            enabled = true
1177            max_chunks = 20
1178            score_threshold = 0.3
1179        ";
1180        let cfg: IndexConfig = toml::from_str(toml).unwrap();
1181        assert!(cfg.enabled);
1182        assert_eq!(cfg.max_chunks, 20);
1183        assert!(cfg.workspace_root.is_none());
1184        assert_eq!(cfg.concurrency, 2);
1185        assert_eq!(cfg.batch_size, 32);
1186        assert_eq!(cfg.initial_pass_batch_delay_ms, 75);
1187    }
1188
1189    #[test]
1190    fn index_config_workspace_root_none_by_default() {
1191        let cfg: IndexConfig = toml::from_str("enabled = false").unwrap();
1192        assert!(cfg.workspace_root.is_none());
1193    }
1194
1195    #[test]
1196    fn gateway_validate_timeout_zero_is_err() {
1197        let cfg = GatewayConfig {
1198            webhook_send_timeout_secs: 0,
1199            ..GatewayConfig::default()
1200        };
1201        assert!(cfg.validate().is_err());
1202    }
1203
1204    #[test]
1205    fn gateway_validate_timeout_over_limit_is_err() {
1206        let cfg = GatewayConfig {
1207            webhook_send_timeout_secs: 301,
1208            ..GatewayConfig::default()
1209        };
1210        assert!(cfg.validate().is_err());
1211    }
1212
1213    #[test]
1214    fn gateway_validate_max_body_over_limit_is_err() {
1215        let cfg = GatewayConfig {
1216            max_body_size: 10 * 1024 * 1024 + 1,
1217            ..GatewayConfig::default()
1218        };
1219        assert!(cfg.validate().is_err());
1220    }
1221
1222    #[test]
1223    fn gateway_validate_defaults_are_ok() {
1224        assert!(GatewayConfig::default().validate().is_ok());
1225    }
1226
1227    #[test]
1228    fn gateway_validate_rate_limit_zero_is_err() {
1229        let cfg = GatewayConfig {
1230            rate_limit: 0,
1231            ..GatewayConfig::default()
1232        };
1233        assert!(cfg.validate().is_err());
1234    }
1235
1236    #[test]
1237    fn scheduler_config_default_is_disabled() {
1238        let cfg = SchedulerConfig::default();
1239        assert!(
1240            !cfg.enabled,
1241            "scheduler must be opt-in (enabled = false by default)"
1242        );
1243    }
1244}
1245
1246// --- CompressionSpectrumConfig defaults ---
1247
1248fn default_compression_spectrum_promotion_window() -> usize {
1249    200
1250}
1251
1252fn default_compression_spectrum_min_occurrences() -> u32 {
1253    3
1254}
1255
1256fn default_compression_spectrum_min_sessions() -> u32 {
1257    2
1258}
1259
1260fn default_compression_spectrum_cluster_threshold() -> f32 {
1261    0.85
1262}
1263
1264fn default_retrieval_low_budget_ratio() -> f32 {
1265    0.20
1266}
1267
1268fn default_retrieval_mid_budget_ratio() -> f32 {
1269    0.50
1270}
1271
1272/// Experience compression spectrum configuration, nested under `[memory.compression_spectrum]`.
1273///
1274/// When `enabled = true`, the agent uses a three-tier memory retrieval policy
1275/// (Episodic → Procedural → Declarative) keyed on remaining token budget, and
1276/// runs a background promotion engine that converts recurring episodic patterns
1277/// into generated SKILL.md files.
1278///
1279/// # Example (TOML)
1280///
1281/// ```toml
1282/// [memory.compression_spectrum]
1283/// enabled = true
1284/// promotion_output_dir = "~/.config/zeph/skills/promoted"
1285/// promotion_provider = "quality"
1286/// ```
1287#[derive(Debug, Deserialize, Serialize)]
1288pub struct CompressionSpectrumConfig {
1289    /// Enable the compression spectrum. Default: `false`.
1290    #[serde(default)]
1291    pub enabled: bool,
1292    /// Directory where promoted SKILL.md files are written.
1293    #[serde(default)]
1294    pub promotion_output_dir: Option<String>,
1295    /// Provider name for SKILL.md generation during promotion. Empty = primary provider.
1296    #[serde(default)]
1297    pub promotion_provider: ProviderName,
1298    /// Maximum number of recent episodic messages to scan for promotion candidates.
1299    /// Default: `200`.
1300    #[serde(default = "default_compression_spectrum_promotion_window")]
1301    pub promotion_window: usize,
1302    /// Minimum number of times a pattern must appear across all sessions to be promoted.
1303    /// Default: `3`.
1304    #[serde(default = "default_compression_spectrum_min_occurrences")]
1305    pub min_occurrences: u32,
1306    /// Minimum number of distinct sessions containing the pattern. Default: `2`.
1307    #[serde(default = "default_compression_spectrum_min_sessions")]
1308    pub min_sessions: u32,
1309    /// Cosine similarity threshold for clustering episodic messages. Default: `0.85`.
1310    #[serde(default = "default_compression_spectrum_cluster_threshold")]
1311    pub cluster_threshold: f32,
1312    /// Remaining-token ratio below which only episodic recall is used. Default: `0.20`.
1313    #[serde(default = "default_retrieval_low_budget_ratio")]
1314    pub retrieval_low_budget_ratio: f32,
1315    /// Remaining-token ratio below which episodic + procedural recall is used. Default: `0.50`.
1316    #[serde(default = "default_retrieval_mid_budget_ratio")]
1317    pub retrieval_mid_budget_ratio: f32,
1318}
1319
1320impl Default for CompressionSpectrumConfig {
1321    fn default() -> Self {
1322        Self {
1323            enabled: false,
1324            promotion_output_dir: None,
1325            promotion_provider: ProviderName::default(),
1326            promotion_window: default_compression_spectrum_promotion_window(),
1327            min_occurrences: default_compression_spectrum_min_occurrences(),
1328            min_sessions: default_compression_spectrum_min_sessions(),
1329            cluster_threshold: default_compression_spectrum_cluster_threshold(),
1330            retrieval_low_budget_ratio: default_retrieval_low_budget_ratio(),
1331            retrieval_mid_budget_ratio: default_retrieval_mid_budget_ratio(),
1332        }
1333    }
1334}
1335
1336fn default_trace_service_name() -> String {
1337    "zeph".into()
1338}
1339
1340/// Configuration for OTel-compatible trace dumps (`format = "trace"`).
1341///
1342/// When `format = "trace"`, the `TracingCollector` writes a `trace.json` file in OTLP JSON
1343/// format at session end. Legacy numbered dump files are NOT written by default (C-03).
1344/// When the `otel` feature is enabled and `otlp_endpoint` is set, spans are also exported
1345/// via OTLP gRPC.
1346#[derive(Debug, Clone, Deserialize, Serialize)]
1347#[serde(default)]
1348pub struct TraceConfig {
1349    /// OTLP gRPC endpoint (only used when `otel` feature is enabled).
1350    /// Default: `"http://localhost:4317"`.
1351    #[serde(default = "default_otlp_endpoint")]
1352    pub otlp_endpoint: String,
1353    /// Service name reported to the `OTel` collector.
1354    #[serde(default = "default_trace_service_name")]
1355    pub service_name: String,
1356    /// Redact sensitive data in span attributes (default: `true`) (C-01).
1357    #[serde(default = "default_true")]
1358    pub redact: bool,
1359}
1360
1361impl Default for TraceConfig {
1362    fn default() -> Self {
1363        Self {
1364            otlp_endpoint: default_otlp_endpoint(),
1365            service_name: default_trace_service_name(),
1366            redact: true,
1367        }
1368    }
1369}
1370
1371/// Debug dump configuration, nested under `[debug]` in TOML.
1372///
1373/// When `enabled = true`, LLM request/response payloads are written to disk for inspection.
1374/// Each session creates a subdirectory under `output_dir` named by session ID.
1375///
1376/// # Example (TOML)
1377///
1378/// ```toml
1379/// [debug]
1380/// enabled = true
1381/// format = "raw"
1382/// ```
1383#[derive(Debug, Clone, Deserialize, Serialize)]
1384#[serde(default)]
1385pub struct DebugConfig {
1386    /// Enable debug dump on startup (CLI `--debug-dump` takes priority).
1387    pub enabled: bool,
1388    /// Directory where per-session debug dump subdirectories are created.
1389    #[serde(default = "crate::defaults::default_debug_output_dir")]
1390    pub output_dir: std::path::PathBuf,
1391    /// Output format: `"json"` (default), `"raw"` (API payload), or `"trace"` (OTLP spans).
1392    pub format: crate::dump_format::DumpFormat,
1393    /// `OTel` trace configuration (only used when `format = "trace"`).
1394    pub traces: TraceConfig,
1395}
1396
1397impl Default for DebugConfig {
1398    fn default() -> Self {
1399        Self {
1400            enabled: false,
1401            output_dir: super::defaults::default_debug_output_dir(),
1402            format: crate::dump_format::DumpFormat::default(),
1403            traces: TraceConfig::default(),
1404        }
1405    }
1406}
1407
1408/// Output style configuration for caveman ultra-compressed mode (`[caveman]`).
1409///
1410/// When `default_on = true` every new session starts in caveman mode. The mode can also be
1411/// toggled at runtime via the `/caveman` command or activated by the bundled `caveman` skill.
1412///
1413/// All fields have `#[serde(default)]` so existing configs parse without changes.
1414///
1415/// # Examples
1416///
1417/// ```
1418/// use zeph_config::CavemanConfig;
1419/// let cfg = CavemanConfig::default();
1420/// assert!(!cfg.default_on);
1421/// ```
1422#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1423pub struct CavemanConfig {
1424    /// Start every session in ultra-compressed (telegraphic) output mode.
1425    ///
1426    /// Default: `false` (opt-in). Can be toggled at runtime with `/caveman [on|off]`.
1427    // TODO(critic): style knobs deferred — see #4985 MVP scope
1428    #[serde(default)]
1429    pub default_on: bool,
1430}