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