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