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