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