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