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_gateway_bind() -> String {
132    "127.0.0.1".into()
133}
134
135fn default_gateway_port() -> u16 {
136    8090
137}
138
139fn default_gateway_rate_limit() -> u32 {
140    120
141}
142
143fn default_gateway_max_body() -> usize {
144    1_048_576
145}
146
147/// Controls how skills are formatted in the system prompt.
148#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
149#[serde(rename_all = "lowercase")]
150pub enum SkillPromptMode {
151    Full,
152    Compact,
153    #[default]
154    Auto,
155}
156
157#[derive(Debug, Deserialize, Serialize)]
158pub struct SkillsConfig {
159    #[serde(default = "default_skill_paths")]
160    pub paths: Vec<String>,
161    #[serde(default = "default_max_active_skills")]
162    pub max_active_skills: usize,
163    #[serde(default = "default_disambiguation_threshold")]
164    pub disambiguation_threshold: f32,
165    #[serde(default = "default_min_injection_score")]
166    pub min_injection_score: f32,
167    #[serde(default = "default_cosine_weight")]
168    pub cosine_weight: f32,
169    #[serde(default = "default_hybrid_search")]
170    pub hybrid_search: bool,
171    #[serde(default)]
172    pub learning: LearningConfig,
173    #[serde(default)]
174    pub trust: TrustConfig,
175    #[serde(default)]
176    pub prompt_mode: SkillPromptMode,
177    /// Enable two-stage category-first skill matching (requires `category` set in SKILL.md).
178    /// Falls back to flat matching when no multi-skill categories are available.
179    #[serde(default)]
180    pub two_stage_matching: bool,
181    /// Warn when any two skills have cosine similarity ≥ this threshold.
182    /// Set to 0.0 (default) to disable the confusability check entirely.
183    #[serde(default)]
184    pub confusability_threshold: f32,
185
186    // --- SkillOrchestra: RL routing head ---
187    /// Enable RL routing head for skill re-ranking (disabled by default).
188    #[serde(default)]
189    pub rl_routing_enabled: bool,
190    /// Learning rate for REINFORCE weight updates.
191    #[serde(default = "default_rl_learning_rate")]
192    pub rl_learning_rate: f32,
193    /// Blend weight: `final_score = (1-rl_weight)*cosine + rl_weight*rl_score`.
194    #[serde(default = "default_rl_weight")]
195    pub rl_weight: f32,
196    /// Persist weights every N updates (0 = persist every update).
197    #[serde(default = "default_rl_persist_interval")]
198    pub rl_persist_interval: u32,
199    /// Skip RL blending for the first N updates (cold-start warmup).
200    #[serde(default = "default_rl_warmup_updates")]
201    pub rl_warmup_updates: u32,
202    /// Embedding dimension for the RL routing head.
203    /// Must match the output dimension of the configured embedding provider.
204    /// Defaults to `None` → 1536 (`text-embedding-3-small` output dimension).
205    #[serde(default)]
206    pub rl_embed_dim: Option<usize>,
207
208    // --- NL skill generation ---
209    /// Provider name for `/skill create` NL generation. Empty = primary provider.
210    #[serde(default)]
211    pub generation_provider: ProviderName,
212    /// Directory where generated skills are written. Defaults to first entry in `paths`.
213    #[serde(default)]
214    pub generation_output_dir: Option<String>,
215    /// Skill mining configuration.
216    #[serde(default)]
217    pub mining: SkillMiningConfig,
218}
219
220fn default_max_repos_per_query() -> usize {
221    20
222}
223
224fn default_dedup_threshold() -> f32 {
225    0.85
226}
227
228fn default_rate_limit_rpm() -> u32 {
229    25
230}
231
232/// Configuration for the automated skill mining pipeline (`zeph-skills-miner` binary).
233#[derive(Debug, Default, Deserialize, Serialize)]
234pub struct SkillMiningConfig {
235    /// GitHub search queries for repo discovery (e.g. "topic:cli-tool language:rust stars:>100").
236    #[serde(default)]
237    pub queries: Vec<String>,
238    /// Maximum repos to fetch per query (capped at 100 by GitHub API). Default: 20.
239    #[serde(default = "default_max_repos_per_query")]
240    pub max_repos_per_query: usize,
241    /// Cosine similarity threshold for dedup against existing skills. Default: 0.85.
242    #[serde(default = "default_dedup_threshold")]
243    pub dedup_threshold: f32,
244    /// Output directory for mined skills.
245    #[serde(default)]
246    pub output_dir: Option<String>,
247    /// Provider name for skill generation during mining. Empty = primary provider.
248    #[serde(default)]
249    pub generation_provider: ProviderName,
250    /// Provider name for embedding during dedup. Empty = primary provider.
251    #[serde(default)]
252    pub embedding_provider: ProviderName,
253    /// Maximum GitHub search requests per minute. Default: 25.
254    #[serde(default = "default_rate_limit_rpm")]
255    pub rate_limit_rpm: u32,
256}
257
258#[derive(Debug, Deserialize, Serialize)]
259#[allow(clippy::struct_excessive_bools)]
260pub struct IndexConfig {
261    #[serde(default)]
262    pub enabled: bool,
263    #[serde(default = "default_index_search_enabled")]
264    pub search_enabled: bool,
265    #[serde(default = "default_index_watch")]
266    pub watch: bool,
267    #[serde(default = "default_index_max_chunks")]
268    pub max_chunks: usize,
269    #[serde(default = "default_index_score_threshold")]
270    pub score_threshold: f32,
271    #[serde(default = "default_index_budget_ratio")]
272    pub budget_ratio: f32,
273    #[serde(default = "default_index_repo_map_tokens")]
274    pub repo_map_tokens: usize,
275    #[serde(default = "default_repo_map_ttl_secs")]
276    pub repo_map_ttl_secs: u64,
277    /// Enable `IndexMcpServer` tools (`symbol_definition`, `find_text_references`, `call_graph`,
278    /// `module_summary`). When `true`, static repo-map injection is skipped and the LLM
279    /// uses on-demand tool calls instead.
280    #[serde(default)]
281    pub mcp_enabled: bool,
282    /// Root directory to index. When `None`, falls back to the current working directory at
283    /// startup. Relative paths are resolved relative to the process working directory.
284    #[serde(default)]
285    pub workspace_root: Option<std::path::PathBuf>,
286    /// Number of files to process concurrently during initial indexing. Default: 4.
287    #[serde(default = "default_index_concurrency")]
288    pub concurrency: usize,
289    /// Maximum number of new chunks to batch into a single Qdrant upsert per file. Default: 32.
290    #[serde(default = "default_index_batch_size")]
291    pub batch_size: usize,
292    /// Number of files to process per memory batch during initial indexing.
293    /// After each batch the stream is dropped and the executor yields to allow
294    /// the allocator to reclaim pages. Default: `32`.
295    #[serde(default = "default_index_memory_batch_size")]
296    pub memory_batch_size: usize,
297    /// Maximum file size in bytes to index. Files larger than this are skipped.
298    /// Protects against large generated files (e.g. lock files, minified JS).
299    /// Default: 512 KiB.
300    #[serde(default = "default_index_max_file_bytes")]
301    pub max_file_bytes: usize,
302    /// Name of a `[[llm.providers]]` entry to use exclusively for embedding calls during
303    /// indexing. A dedicated provider prevents the indexer from contending with the guardrail
304    /// at the API server level (rate limits, Ollama single-model lock). When unset or empty,
305    /// falls back to the main agent provider.
306    #[serde(default)]
307    pub embed_provider: Option<String>,
308    /// Maximum parallel `embed_batch` calls during indexing (default: 2 to stay within provider
309    /// TPM limits).
310    #[serde(default = "default_index_embed_concurrency")]
311    pub embed_concurrency: usize,
312}
313
314impl Default for IndexConfig {
315    fn default() -> Self {
316        Self {
317            enabled: false,
318            search_enabled: default_index_search_enabled(),
319            watch: default_index_watch(),
320            max_chunks: default_index_max_chunks(),
321            score_threshold: default_index_score_threshold(),
322            budget_ratio: default_index_budget_ratio(),
323            repo_map_tokens: default_index_repo_map_tokens(),
324            repo_map_ttl_secs: default_repo_map_ttl_secs(),
325            mcp_enabled: false,
326            workspace_root: None,
327            concurrency: default_index_concurrency(),
328            batch_size: default_index_batch_size(),
329            memory_batch_size: default_index_memory_batch_size(),
330            max_file_bytes: default_index_max_file_bytes(),
331            embed_provider: None,
332            embed_concurrency: default_index_embed_concurrency(),
333        }
334    }
335}
336
337#[derive(Debug, Deserialize, Serialize)]
338pub struct VaultConfig {
339    #[serde(default = "default_vault_backend")]
340    pub backend: String,
341}
342
343impl Default for VaultConfig {
344    fn default() -> Self {
345        Self {
346            backend: default_vault_backend(),
347        }
348    }
349}
350
351#[derive(Debug, Deserialize, Serialize)]
352pub struct CostConfig {
353    #[serde(default = "default_true")]
354    pub enabled: bool,
355    #[serde(default = "default_max_daily_cents")]
356    pub max_daily_cents: u32,
357}
358
359impl Default for CostConfig {
360    fn default() -> Self {
361        Self {
362            enabled: true,
363            max_daily_cents: default_max_daily_cents(),
364        }
365    }
366}
367
368#[derive(Debug, Deserialize, Serialize)]
369pub struct ObservabilityConfig {
370    #[serde(default)]
371    pub exporter: String,
372    #[serde(default = "default_otlp_endpoint")]
373    pub endpoint: String,
374}
375
376impl Default for ObservabilityConfig {
377    fn default() -> Self {
378        Self {
379            exporter: String::new(),
380            endpoint: default_otlp_endpoint(),
381        }
382    }
383}
384
385#[derive(Debug, Clone, Deserialize, Serialize)]
386pub struct GatewayConfig {
387    #[serde(default)]
388    pub enabled: bool,
389    #[serde(default = "default_gateway_bind")]
390    pub bind: String,
391    #[serde(default = "default_gateway_port")]
392    pub port: u16,
393    #[serde(default)]
394    pub auth_token: Option<String>,
395    #[serde(default = "default_gateway_rate_limit")]
396    pub rate_limit: u32,
397    #[serde(default = "default_gateway_max_body")]
398    pub max_body_size: usize,
399}
400
401impl Default for GatewayConfig {
402    fn default() -> Self {
403        Self {
404            enabled: false,
405            bind: default_gateway_bind(),
406            port: default_gateway_port(),
407            auth_token: None,
408            rate_limit: default_gateway_rate_limit(),
409            max_body_size: default_gateway_max_body(),
410        }
411    }
412}
413
414#[derive(Debug, Clone, Deserialize, Serialize)]
415pub struct DaemonConfig {
416    #[serde(default)]
417    pub enabled: bool,
418    #[serde(default = "default_pid_file")]
419    pub pid_file: String,
420    #[serde(default = "default_health_interval")]
421    pub health_interval_secs: u64,
422    #[serde(default = "default_max_restart_backoff")]
423    pub max_restart_backoff_secs: u64,
424}
425
426impl Default for DaemonConfig {
427    fn default() -> Self {
428        Self {
429            enabled: false,
430            pid_file: default_pid_file(),
431            health_interval_secs: default_health_interval(),
432            max_restart_backoff_secs: default_max_restart_backoff(),
433        }
434    }
435}
436
437#[derive(Debug, Clone, Deserialize, Serialize)]
438pub struct SchedulerConfig {
439    #[serde(default)]
440    pub enabled: bool,
441    #[serde(default = "default_scheduler_tick_interval")]
442    pub tick_interval_secs: u64,
443    #[serde(default = "default_scheduler_max_tasks")]
444    pub max_tasks: usize,
445    #[serde(default)]
446    pub tasks: Vec<ScheduledTaskConfig>,
447}
448
449impl Default for SchedulerConfig {
450    fn default() -> Self {
451        Self {
452            enabled: true,
453            tick_interval_secs: default_scheduler_tick_interval(),
454            max_tasks: default_scheduler_max_tasks(),
455            tasks: Vec::new(),
456        }
457    }
458}
459
460/// Task kind for scheduled tasks.
461///
462/// Known variants map to built-in handlers; `Custom` accommodates user-defined task types.
463#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
464#[serde(rename_all = "snake_case")]
465pub enum ScheduledTaskKind {
466    MemoryCleanup,
467    SkillRefresh,
468    HealthCheck,
469    UpdateCheck,
470    Experiment,
471    Custom(String),
472}
473
474#[derive(Debug, Clone, Deserialize, Serialize)]
475pub struct ScheduledTaskConfig {
476    pub name: String,
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub cron: Option<String>,
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    pub run_at: Option<String>,
481    pub kind: ScheduledTaskKind,
482    #[serde(default)]
483    pub config: serde_json::Value,
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn index_config_defaults() {
492        let cfg = IndexConfig::default();
493        assert!(!cfg.enabled);
494        assert!(cfg.search_enabled);
495        assert!(!cfg.watch);
496        assert_eq!(cfg.concurrency, 4);
497        assert_eq!(cfg.batch_size, 32);
498        assert!(cfg.workspace_root.is_none());
499    }
500
501    #[test]
502    fn index_config_serde_roundtrip_with_new_fields() {
503        let toml = r#"
504            enabled = true
505            concurrency = 8
506            batch_size = 16
507            workspace_root = "/tmp/myproject"
508        "#;
509        let cfg: IndexConfig = toml::from_str(toml).unwrap();
510        assert!(cfg.enabled);
511        assert_eq!(cfg.concurrency, 8);
512        assert_eq!(cfg.batch_size, 16);
513        assert_eq!(
514            cfg.workspace_root,
515            Some(std::path::PathBuf::from("/tmp/myproject"))
516        );
517        // Re-serialize and deserialize
518        let serialized = toml::to_string(&cfg).unwrap();
519        let cfg2: IndexConfig = toml::from_str(&serialized).unwrap();
520        assert_eq!(cfg2.concurrency, 8);
521        assert_eq!(cfg2.batch_size, 16);
522    }
523
524    #[test]
525    fn index_config_backward_compat_old_toml_without_new_fields() {
526        // Old config without workspace_root, concurrency, batch_size — must still parse
527        // and use defaults for the missing fields.
528        let toml = "
529            enabled = true
530            max_chunks = 20
531            score_threshold = 0.3
532        ";
533        let cfg: IndexConfig = toml::from_str(toml).unwrap();
534        assert!(cfg.enabled);
535        assert_eq!(cfg.max_chunks, 20);
536        assert!(cfg.workspace_root.is_none());
537        assert_eq!(cfg.concurrency, 4);
538        assert_eq!(cfg.batch_size, 32);
539    }
540
541    #[test]
542    fn index_config_workspace_root_none_by_default() {
543        let cfg: IndexConfig = toml::from_str("enabled = false").unwrap();
544        assert!(cfg.workspace_root.is_none());
545    }
546}
547
548fn default_trace_service_name() -> String {
549    "zeph".into()
550}
551
552/// Configuration for OTel-compatible trace dumps (`format = "trace"`).
553///
554/// When `format = "trace"`, the `TracingCollector` writes a `trace.json` file in OTLP JSON
555/// format at session end. Legacy numbered dump files are NOT written by default (C-03).
556/// When the `otel` feature is enabled and `otlp_endpoint` is set, spans are also exported
557/// via OTLP gRPC.
558#[derive(Debug, Clone, Deserialize, Serialize)]
559#[serde(default)]
560pub struct TraceConfig {
561    /// OTLP gRPC endpoint (only used when `otel` feature is enabled).
562    /// Defaults to `observability.endpoint` if unset (I-01).
563    #[serde(default = "default_otlp_endpoint")]
564    pub otlp_endpoint: String,
565    /// Service name reported to the `OTel` collector.
566    #[serde(default = "default_trace_service_name")]
567    pub service_name: String,
568    /// Redact sensitive data in span attributes (default: `true`) (C-01).
569    #[serde(default = "default_true")]
570    pub redact: bool,
571}
572
573impl Default for TraceConfig {
574    fn default() -> Self {
575        Self {
576            otlp_endpoint: default_otlp_endpoint(),
577            service_name: default_trace_service_name(),
578            redact: true,
579        }
580    }
581}
582
583#[derive(Debug, Clone, Deserialize, Serialize)]
584#[serde(default)]
585pub struct DebugConfig {
586    /// Enable debug dump on startup (CLI `--debug-dump` takes priority).
587    pub enabled: bool,
588    /// Directory where per-session debug dump subdirectories are created.
589    #[serde(default = "crate::defaults::default_debug_output_dir")]
590    pub output_dir: std::path::PathBuf,
591    /// Output format: `"json"` (default), `"raw"` (API payload), or `"trace"` (OTLP spans).
592    pub format: crate::dump_format::DumpFormat,
593    /// `OTel` trace configuration (only used when `format = "trace"`).
594    pub traces: TraceConfig,
595}
596
597impl Default for DebugConfig {
598    fn default() -> Self {
599        Self {
600            enabled: false,
601            output_dir: super::defaults::default_debug_output_dir(),
602            format: crate::dump_format::DumpFormat::default(),
603            traces: TraceConfig::default(),
604        }
605    }
606}