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/// Skill discovery and matching configuration, nested under `[skills]` in TOML.
158///
159/// Controls where skills are loaded from, how they are ranked during retrieval,
160/// the RL re-ranking head, NL skill generation, and automated skill mining.
161///
162/// # Example (TOML)
163///
164/// ```toml
165/// [skills]
166/// paths = ["~/.config/zeph/skills"]
167/// max_active_skills = 5
168/// disambiguation_threshold = 0.20
169/// hybrid_search = true
170/// ```
171#[derive(Debug, Deserialize, Serialize)]
172pub struct SkillsConfig {
173    /// Directories to scan for `*.skill.md` / `SKILL.md` files.
174    #[serde(default = "default_skill_paths")]
175    pub paths: Vec<String>,
176    #[serde(default = "default_max_active_skills")]
177    pub max_active_skills: usize,
178    #[serde(default = "default_disambiguation_threshold")]
179    pub disambiguation_threshold: f32,
180    #[serde(default = "default_min_injection_score")]
181    pub min_injection_score: f32,
182    #[serde(default = "default_cosine_weight")]
183    pub cosine_weight: f32,
184    #[serde(default = "default_hybrid_search")]
185    pub hybrid_search: bool,
186    #[serde(default)]
187    pub learning: LearningConfig,
188    #[serde(default)]
189    pub trust: TrustConfig,
190    #[serde(default)]
191    pub prompt_mode: SkillPromptMode,
192    /// Enable two-stage category-first skill matching (requires `category` set in SKILL.md).
193    /// Falls back to flat matching when no multi-skill categories are available.
194    #[serde(default)]
195    pub two_stage_matching: bool,
196    /// Warn when any two skills have cosine similarity ≥ this threshold.
197    /// Set to 0.0 (default) to disable the confusability check entirely.
198    #[serde(default)]
199    pub confusability_threshold: f32,
200
201    // --- SkillOrchestra: RL routing head ---
202    /// Enable RL routing head for skill re-ranking (disabled by default).
203    #[serde(default)]
204    pub rl_routing_enabled: bool,
205    /// Learning rate for REINFORCE weight updates.
206    #[serde(default = "default_rl_learning_rate")]
207    pub rl_learning_rate: f32,
208    /// Blend weight: `final_score = (1-rl_weight)*cosine + rl_weight*rl_score`.
209    #[serde(default = "default_rl_weight")]
210    pub rl_weight: f32,
211    /// Persist weights every N updates (0 = persist every update).
212    #[serde(default = "default_rl_persist_interval")]
213    pub rl_persist_interval: u32,
214    /// Skip RL blending for the first N updates (cold-start warmup).
215    #[serde(default = "default_rl_warmup_updates")]
216    pub rl_warmup_updates: u32,
217    /// Embedding dimension for the RL routing head.
218    /// Must match the output dimension of the configured embedding provider.
219    /// Defaults to `None` → 1536 (`text-embedding-3-small` output dimension).
220    #[serde(default)]
221    pub rl_embed_dim: Option<usize>,
222
223    // --- NL skill generation ---
224    /// Provider name for `/skill create` NL generation. Empty = primary provider.
225    #[serde(default)]
226    pub generation_provider: ProviderName,
227    /// Directory where generated skills are written. Defaults to first entry in `paths`.
228    #[serde(default)]
229    pub generation_output_dir: Option<String>,
230    /// Skill mining configuration.
231    #[serde(default)]
232    pub mining: SkillMiningConfig,
233}
234
235fn default_max_repos_per_query() -> usize {
236    20
237}
238
239fn default_dedup_threshold() -> f32 {
240    0.85
241}
242
243fn default_rate_limit_rpm() -> u32 {
244    25
245}
246
247/// Configuration for the automated skill mining pipeline (`zeph-skills-miner` binary).
248#[derive(Debug, Default, Deserialize, Serialize)]
249pub struct SkillMiningConfig {
250    /// GitHub search queries for repo discovery (e.g. "topic:cli-tool language:rust stars:>100").
251    #[serde(default)]
252    pub queries: Vec<String>,
253    /// Maximum repos to fetch per query (capped at 100 by GitHub API). Default: 20.
254    #[serde(default = "default_max_repos_per_query")]
255    pub max_repos_per_query: usize,
256    /// Cosine similarity threshold for dedup against existing skills. Default: 0.85.
257    #[serde(default = "default_dedup_threshold")]
258    pub dedup_threshold: f32,
259    /// Output directory for mined skills.
260    #[serde(default)]
261    pub output_dir: Option<String>,
262    /// Provider name for skill generation during mining. Empty = primary provider.
263    #[serde(default)]
264    pub generation_provider: ProviderName,
265    /// Provider name for embedding during dedup. Empty = primary provider.
266    #[serde(default)]
267    pub embedding_provider: ProviderName,
268    /// Maximum GitHub search requests per minute. Default: 25.
269    #[serde(default = "default_rate_limit_rpm")]
270    pub rate_limit_rpm: u32,
271}
272
273/// Code indexing and repo-map configuration, nested under `[index]` in TOML.
274///
275/// When `enabled = true`, the agent indexes source files into Qdrant for semantic
276/// code search. The repo map is injected into the system prompt or served via
277/// `IndexMcpServer` tool calls when `mcp_enabled = true`.
278///
279/// # Example (TOML)
280///
281/// ```toml
282/// [index]
283/// enabled = true
284/// watch = false
285/// max_chunks = 12
286/// score_threshold = 0.25
287/// ```
288#[derive(Debug, Deserialize, Serialize)]
289#[allow(clippy::struct_excessive_bools)]
290pub struct IndexConfig {
291    /// Enable code indexing. Default: `false`.
292    #[serde(default)]
293    pub enabled: bool,
294    /// Enable semantic code search tool. Default: `true` (no-op when `enabled = false`).
295    #[serde(default = "default_index_search_enabled")]
296    pub search_enabled: bool,
297    #[serde(default = "default_index_watch")]
298    pub watch: bool,
299    #[serde(default = "default_index_max_chunks")]
300    pub max_chunks: usize,
301    #[serde(default = "default_index_score_threshold")]
302    pub score_threshold: f32,
303    #[serde(default = "default_index_budget_ratio")]
304    pub budget_ratio: f32,
305    #[serde(default = "default_index_repo_map_tokens")]
306    pub repo_map_tokens: usize,
307    #[serde(default = "default_repo_map_ttl_secs")]
308    pub repo_map_ttl_secs: u64,
309    /// Enable `IndexMcpServer` tools (`symbol_definition`, `find_text_references`, `call_graph`,
310    /// `module_summary`). When `true`, static repo-map injection is skipped and the LLM
311    /// uses on-demand tool calls instead.
312    #[serde(default)]
313    pub mcp_enabled: bool,
314    /// Root directory to index. When `None`, falls back to the current working directory at
315    /// startup. Relative paths are resolved relative to the process working directory.
316    #[serde(default)]
317    pub workspace_root: Option<std::path::PathBuf>,
318    /// Number of files to process concurrently during initial indexing. Default: 4.
319    #[serde(default = "default_index_concurrency")]
320    pub concurrency: usize,
321    /// Maximum number of new chunks to batch into a single Qdrant upsert per file. Default: 32.
322    #[serde(default = "default_index_batch_size")]
323    pub batch_size: usize,
324    /// Number of files to process per memory batch during initial indexing.
325    /// After each batch the stream is dropped and the executor yields to allow
326    /// the allocator to reclaim pages. Default: `32`.
327    #[serde(default = "default_index_memory_batch_size")]
328    pub memory_batch_size: usize,
329    /// Maximum file size in bytes to index. Files larger than this are skipped.
330    /// Protects against large generated files (e.g. lock files, minified JS).
331    /// Default: 512 KiB.
332    #[serde(default = "default_index_max_file_bytes")]
333    pub max_file_bytes: usize,
334    /// Name of a `[[llm.providers]]` entry to use exclusively for embedding calls during
335    /// indexing. A dedicated provider prevents the indexer from contending with the guardrail
336    /// at the API server level (rate limits, Ollama single-model lock). When unset or empty,
337    /// falls back to the main agent provider.
338    #[serde(default)]
339    pub embed_provider: Option<String>,
340    /// Maximum parallel `embed_batch` calls during indexing (default: 2 to stay within provider
341    /// TPM limits).
342    #[serde(default = "default_index_embed_concurrency")]
343    pub embed_concurrency: usize,
344}
345
346impl Default for IndexConfig {
347    fn default() -> Self {
348        Self {
349            enabled: false,
350            search_enabled: default_index_search_enabled(),
351            watch: default_index_watch(),
352            max_chunks: default_index_max_chunks(),
353            score_threshold: default_index_score_threshold(),
354            budget_ratio: default_index_budget_ratio(),
355            repo_map_tokens: default_index_repo_map_tokens(),
356            repo_map_ttl_secs: default_repo_map_ttl_secs(),
357            mcp_enabled: false,
358            workspace_root: None,
359            concurrency: default_index_concurrency(),
360            batch_size: default_index_batch_size(),
361            memory_batch_size: default_index_memory_batch_size(),
362            max_file_bytes: default_index_max_file_bytes(),
363            embed_provider: None,
364            embed_concurrency: default_index_embed_concurrency(),
365        }
366    }
367}
368
369/// Vault backend configuration, nested under `[vault]` in TOML.
370///
371/// Selects how API keys and secrets are resolved at startup.
372///
373/// # Example (TOML)
374///
375/// ```toml
376/// [vault]
377/// backend = "age"
378/// ```
379#[derive(Debug, Deserialize, Serialize)]
380pub struct VaultConfig {
381    /// Vault backend identifier (`"age"`, `"env"`, or `"keyring"`). Default: `"env"`.
382    #[serde(default = "default_vault_backend")]
383    pub backend: String,
384}
385
386impl Default for VaultConfig {
387    fn default() -> Self {
388        Self {
389            backend: default_vault_backend(),
390        }
391    }
392}
393
394/// Cost tracking and budget configuration, nested under `[cost]` in TOML.
395///
396/// When `enabled = true`, token costs are accumulated per session and displayed in
397/// the TUI. When `max_daily_cents > 0`, the agent refuses new turns once the daily
398/// budget is exhausted.
399///
400/// # Example (TOML)
401///
402/// ```toml
403/// [cost]
404/// enabled = true
405/// max_daily_cents = 500  # $5.00 per day
406/// ```
407#[derive(Debug, Deserialize, Serialize)]
408pub struct CostConfig {
409    /// Track and display token costs. Default: `true`.
410    #[serde(default = "default_true")]
411    pub enabled: bool,
412    /// Daily spending cap in US cents (`0` = unlimited). Default: `0`.
413    #[serde(default = "default_max_daily_cents")]
414    pub max_daily_cents: u32,
415}
416
417impl Default for CostConfig {
418    fn default() -> Self {
419        Self {
420            enabled: true,
421            max_daily_cents: default_max_daily_cents(),
422        }
423    }
424}
425
426/// HTTP webhook gateway configuration, nested under `[gateway]` in TOML.
427///
428/// When `enabled = true`, an HTTP server accepts webhook payloads and injects them
429/// as user messages into the agent. Requires the `gateway` feature flag.
430///
431/// # Example (TOML)
432///
433/// ```toml
434/// [gateway]
435/// enabled = true
436/// bind = "127.0.0.1"
437/// port = 8090
438/// auth_token = "secret"
439/// rate_limit = 60
440/// max_body_size = 1048576
441/// ```
442#[derive(Debug, Clone, Deserialize, Serialize)]
443pub struct GatewayConfig {
444    /// Enable the HTTP gateway. Default: `false`.
445    #[serde(default)]
446    pub enabled: bool,
447    /// IP address to bind the gateway to. Default: `"127.0.0.1"`.
448    #[serde(default = "default_gateway_bind")]
449    pub bind: String,
450    /// Port to listen on. Default: `8090`.
451    #[serde(default = "default_gateway_port")]
452    pub port: u16,
453    /// Bearer token for request authentication. When set, all requests must include
454    /// `Authorization: Bearer <token>`. Default: `None` (no auth).
455    #[serde(default)]
456    pub auth_token: Option<String>,
457    /// Maximum requests per minute. Must be `> 0`. Default: `120`.
458    #[serde(default = "default_gateway_rate_limit")]
459    pub rate_limit: u32,
460    /// Maximum request body size in bytes. Must be `<= 10 MiB`. Default: `1048576` (1 MiB).
461    #[serde(default = "default_gateway_max_body")]
462    pub max_body_size: usize,
463}
464
465impl Default for GatewayConfig {
466    fn default() -> Self {
467        Self {
468            enabled: false,
469            bind: default_gateway_bind(),
470            port: default_gateway_port(),
471            auth_token: None,
472            rate_limit: default_gateway_rate_limit(),
473            max_body_size: default_gateway_max_body(),
474        }
475    }
476}
477
478/// Daemon / process supervisor configuration, nested under `[daemon]` in TOML.
479///
480/// When `enabled = true`, Zeph runs as a background process with automatic restart
481/// and health monitoring.
482///
483/// # Example (TOML)
484///
485/// ```toml
486/// [daemon]
487/// enabled = true
488/// pid_file = "~/.zeph/zeph.pid"
489/// health_interval_secs = 30
490/// ```
491#[derive(Debug, Clone, Deserialize, Serialize)]
492pub struct DaemonConfig {
493    /// Run Zeph as a background daemon. Default: `false`.
494    #[serde(default)]
495    pub enabled: bool,
496    /// Path to the PID file written at daemon startup. Default: `"~/.zeph/zeph.pid"`.
497    #[serde(default = "default_pid_file")]
498    pub pid_file: String,
499    /// Interval in seconds between health checks. Default: `30`.
500    #[serde(default = "default_health_interval")]
501    pub health_interval_secs: u64,
502    /// Maximum backoff in seconds between restart attempts. Default: `60`.
503    #[serde(default = "default_max_restart_backoff")]
504    pub max_restart_backoff_secs: u64,
505}
506
507impl Default for DaemonConfig {
508    fn default() -> Self {
509        Self {
510            enabled: false,
511            pid_file: default_pid_file(),
512            health_interval_secs: default_health_interval(),
513            max_restart_backoff_secs: default_max_restart_backoff(),
514        }
515    }
516}
517
518/// Cron-based task scheduler configuration, nested under `[scheduler]` in TOML.
519///
520/// When `enabled = true`, the scheduler runs periodic tasks on a cron schedule.
521/// Requires the `scheduler` feature flag.
522///
523/// # Example (TOML)
524///
525/// ```toml
526/// [scheduler]
527/// enabled = true
528/// tick_interval_secs = 60
529/// max_tasks = 20
530///
531/// [[scheduler.tasks]]
532/// name = "daily-summary"
533/// cron = "0 9 * * *"
534/// kind = "prompt"
535/// prompt = "Summarize what was accomplished today."
536/// ```
537#[derive(Debug, Clone, Deserialize, Serialize)]
538pub struct SchedulerConfig {
539    /// Enable the task scheduler. Default: `false`.
540    #[serde(default)]
541    pub enabled: bool,
542    /// How often the scheduler checks for due tasks, in seconds. Default: `60`.
543    #[serde(default = "default_scheduler_tick_interval")]
544    pub tick_interval_secs: u64,
545    /// Maximum number of scheduled tasks allowed. Default: `100`.
546    #[serde(default = "default_scheduler_max_tasks")]
547    pub max_tasks: usize,
548    /// List of scheduled task definitions.
549    #[serde(default)]
550    pub tasks: Vec<ScheduledTaskConfig>,
551}
552
553impl Default for SchedulerConfig {
554    fn default() -> Self {
555        Self {
556            enabled: true,
557            tick_interval_secs: default_scheduler_tick_interval(),
558            max_tasks: default_scheduler_max_tasks(),
559            tasks: Vec::new(),
560        }
561    }
562}
563
564/// Task kind for scheduled tasks.
565///
566/// Known variants map to built-in handlers; `Custom` accommodates user-defined task types.
567#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
568#[serde(rename_all = "snake_case")]
569pub enum ScheduledTaskKind {
570    MemoryCleanup,
571    SkillRefresh,
572    HealthCheck,
573    UpdateCheck,
574    Experiment,
575    Custom(String),
576}
577
578/// A single scheduled task entry, nested under `[[scheduler.tasks]]` in TOML.
579///
580/// Either `cron` (recurring) or `run_at` (one-shot ISO 8601 datetime) must be set.
581#[derive(Debug, Clone, Deserialize, Serialize)]
582pub struct ScheduledTaskConfig {
583    /// Unique task name used in logs and the scheduler database.
584    pub name: String,
585    /// Cron expression for recurring tasks (e.g. `"0 9 * * *"` for daily at 09:00).
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    pub cron: Option<String>,
588    /// One-shot ISO 8601 datetime for one-time tasks. Ignored when `cron` is set.
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub run_at: Option<String>,
591    /// Determines which built-in handler executes this task.
592    pub kind: ScheduledTaskKind,
593    /// Arbitrary JSON configuration forwarded to the task handler.
594    #[serde(default)]
595    pub config: serde_json::Value,
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    #[test]
603    fn index_config_defaults() {
604        let cfg = IndexConfig::default();
605        assert!(!cfg.enabled);
606        assert!(cfg.search_enabled);
607        assert!(!cfg.watch);
608        assert_eq!(cfg.concurrency, 4);
609        assert_eq!(cfg.batch_size, 32);
610        assert!(cfg.workspace_root.is_none());
611    }
612
613    #[test]
614    fn index_config_serde_roundtrip_with_new_fields() {
615        let toml = r#"
616            enabled = true
617            concurrency = 8
618            batch_size = 16
619            workspace_root = "/tmp/myproject"
620        "#;
621        let cfg: IndexConfig = toml::from_str(toml).unwrap();
622        assert!(cfg.enabled);
623        assert_eq!(cfg.concurrency, 8);
624        assert_eq!(cfg.batch_size, 16);
625        assert_eq!(
626            cfg.workspace_root,
627            Some(std::path::PathBuf::from("/tmp/myproject"))
628        );
629        // Re-serialize and deserialize
630        let serialized = toml::to_string(&cfg).unwrap();
631        let cfg2: IndexConfig = toml::from_str(&serialized).unwrap();
632        assert_eq!(cfg2.concurrency, 8);
633        assert_eq!(cfg2.batch_size, 16);
634    }
635
636    #[test]
637    fn index_config_backward_compat_old_toml_without_new_fields() {
638        // Old config without workspace_root, concurrency, batch_size — must still parse
639        // and use defaults for the missing fields.
640        let toml = "
641            enabled = true
642            max_chunks = 20
643            score_threshold = 0.3
644        ";
645        let cfg: IndexConfig = toml::from_str(toml).unwrap();
646        assert!(cfg.enabled);
647        assert_eq!(cfg.max_chunks, 20);
648        assert!(cfg.workspace_root.is_none());
649        assert_eq!(cfg.concurrency, 4);
650        assert_eq!(cfg.batch_size, 32);
651    }
652
653    #[test]
654    fn index_config_workspace_root_none_by_default() {
655        let cfg: IndexConfig = toml::from_str("enabled = false").unwrap();
656        assert!(cfg.workspace_root.is_none());
657    }
658}
659
660fn default_trace_service_name() -> String {
661    "zeph".into()
662}
663
664/// Configuration for OTel-compatible trace dumps (`format = "trace"`).
665///
666/// When `format = "trace"`, the `TracingCollector` writes a `trace.json` file in OTLP JSON
667/// format at session end. Legacy numbered dump files are NOT written by default (C-03).
668/// When the `otel` feature is enabled and `otlp_endpoint` is set, spans are also exported
669/// via OTLP gRPC.
670#[derive(Debug, Clone, Deserialize, Serialize)]
671#[serde(default)]
672pub struct TraceConfig {
673    /// OTLP gRPC endpoint (only used when `otel` feature is enabled).
674    /// Default: `"http://localhost:4317"`.
675    #[serde(default = "default_otlp_endpoint")]
676    pub otlp_endpoint: String,
677    /// Service name reported to the `OTel` collector.
678    #[serde(default = "default_trace_service_name")]
679    pub service_name: String,
680    /// Redact sensitive data in span attributes (default: `true`) (C-01).
681    #[serde(default = "default_true")]
682    pub redact: bool,
683}
684
685impl Default for TraceConfig {
686    fn default() -> Self {
687        Self {
688            otlp_endpoint: default_otlp_endpoint(),
689            service_name: default_trace_service_name(),
690            redact: true,
691        }
692    }
693}
694
695/// Debug dump configuration, nested under `[debug]` in TOML.
696///
697/// When `enabled = true`, LLM request/response payloads are written to disk for inspection.
698/// Each session creates a subdirectory under `output_dir` named by session ID.
699///
700/// # Example (TOML)
701///
702/// ```toml
703/// [debug]
704/// enabled = true
705/// format = "raw"
706/// ```
707#[derive(Debug, Clone, Deserialize, Serialize)]
708#[serde(default)]
709pub struct DebugConfig {
710    /// Enable debug dump on startup (CLI `--debug-dump` takes priority).
711    pub enabled: bool,
712    /// Directory where per-session debug dump subdirectories are created.
713    #[serde(default = "crate::defaults::default_debug_output_dir")]
714    pub output_dir: std::path::PathBuf,
715    /// Output format: `"json"` (default), `"raw"` (API payload), or `"trace"` (OTLP spans).
716    pub format: crate::dump_format::DumpFormat,
717    /// `OTel` trace configuration (only used when `format = "trace"`).
718    pub traces: TraceConfig,
719}
720
721impl Default for DebugConfig {
722    fn default() -> Self {
723        Self {
724            enabled: false,
725            output_dir: super::defaults::default_debug_output_dir(),
726            format: crate::dump_format::DumpFormat::default(),
727            traces: TraceConfig::default(),
728        }
729    }
730}