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