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