Skip to main content

oxios_kernel/
config.rs

1#![allow(missing_docs)]
2//! Configuration loading from TOML files.
3//!
4//! Configuration is stored at `~/.oxios/config.toml` and controls
5//! kernel, gateway, and execution settings.
6
7use cron::Schedule;
8use serde::{Deserialize, Serialize};
9use std::str::FromStr;
10
11use crate::email::{SmtpProvider, SmtpTls};
12use crate::scheduler::Priority;
13
14/// Cron scheduler configuration.
15#[derive(Debug, Clone, Deserialize, Serialize)]
16pub struct CronConfig {
17    /// Enable the cron scheduler.
18    #[serde(default)]
19    pub enabled: bool,
20    /// Tick interval in seconds.
21    #[serde(default = "default_tick_interval")]
22    pub tick_interval_secs: u64,
23    /// Inline job definitions from config.toml.
24    #[serde(default)]
25    pub jobs: std::collections::HashMap<String, InlineCronJob>,
26}
27
28impl Default for CronConfig {
29    fn default() -> Self {
30        Self {
31            enabled: false,
32            tick_interval_secs: default_tick_interval(),
33            jobs: std::collections::HashMap::new(),
34        }
35    }
36}
37
38fn default_tick_interval() -> u64 {
39    60
40}
41
42/// Inline cron job definition in config.toml.
43#[derive(Debug, Clone, Deserialize, Serialize)]
44pub struct InlineCronJob {
45    /// Cron expression (e.g. "0 */6 * * *").
46    pub schedule: String,
47    /// Goal description for the agent.
48    pub goal: String,
49    /// Constraints on agent behavior.
50    #[serde(default)]
51    pub constraints: Vec<String>,
52    /// Criteria that must be met for the job to be considered successful.
53    #[serde(default)]
54    pub acceptance_criteria: Vec<String>,
55    /// Toolchain preset name.
56    #[serde(default = "default_toolchain_inline")]
57    pub toolchain: String,
58    /// Job priority.
59    #[serde(default)]
60    pub priority: Priority,
61    /// Whether the job is active.
62    #[serde(default = "default_true_inline")]
63    pub enabled: bool,
64}
65
66fn default_toolchain_inline() -> String {
67    "default".into()
68}
69
70fn default_true_inline() -> bool {
71    true
72}
73
74/// Memory system configuration.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct MemoryConfig {
77    /// Enable the memory system.
78    #[serde(default = "default_true")]
79    pub enabled: bool,
80    /// Maximum memories returned by recall.
81    #[serde(default = "default_max_recall")]
82    pub max_recall: usize,
83    /// Auto-summarize sessions on completion.
84    #[serde(default = "default_true")]
85    pub auto_summarize: bool,
86    /// Capture compaction summaries as conversation memory.
87    #[serde(default = "default_true")]
88    pub capture_compaction: bool,
89    /// Memory retention in days (0 = unlimited).
90    #[serde(default)]
91    pub retention_days: u32,
92    /// Enable embedding cache.
93    #[serde(default = "default_true")]
94    pub cache_enabled: bool,
95    /// Embedding cache TTL in seconds.
96    #[serde(default = "default_cache_ttl")]
97    pub cache_ttl_secs: u64,
98    /// Maximum embedding cache entries.
99    #[serde(default = "default_cache_max_entries")]
100    pub cache_max_entries: usize,
101    /// Consolidation configuration (RFC-008).
102    #[serde(default)]
103    pub consolidation: ConsolidationConfig,
104    /// SQLite memory storage configuration (RFC-012).
105    #[serde(default)]
106    pub sqlite: SqliteMemoryConfig,
107    /// Embedding provider configuration (RFC-012).
108    #[serde(default)]
109    pub embedding: EmbeddingConfig,
110    /// Learning configuration (RFC-012 Phase 4: SONA).
111    #[serde(default)]
112    pub learning: LearningConfig,
113    /// AutoMemoryBridge configuration (RFC-012 Phase 7: SQLite ↔ MEMORY.md sync).
114    #[serde(default)]
115    pub bridge: MemoryBridgeConfig,
116}
117
118fn default_true() -> bool {
119    true
120}
121
122fn default_max_recall() -> usize {
123    10
124}
125
126fn default_cache_ttl() -> u64 {
127    3600 // 1 hour
128}
129
130fn default_cache_max_entries() -> usize {
131    10000
132}
133
134impl Default for MemoryConfig {
135    fn default() -> Self {
136        Self {
137            enabled: true,
138            max_recall: 10,
139            auto_summarize: true,
140            capture_compaction: true,
141            retention_days: 0,
142            cache_enabled: true,
143            cache_ttl_secs: 3600,
144            cache_max_entries: 10000,
145            consolidation: ConsolidationConfig::default(),
146            sqlite: SqliteMemoryConfig::default(),
147            embedding: EmbeddingConfig::default(),
148            learning: LearningConfig::default(),
149            bridge: MemoryBridgeConfig::default(),
150        }
151    }
152}
153
154// ---------------------------------------------------------------------------
155// SqliteMemoryConfig (RFC-012: SQLite Memory Storage)
156// ---------------------------------------------------------------------------
157
158/// SQLite-backed memory storage configuration (RFC-012).
159///
160/// When enabled, memories are stored in a single `memory.db` file with
161/// FTS5 BM25 + sqlite-vec KNN search. Falls back to the existing JSON
162/// + TF-IDF approach when disabled.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct SqliteMemoryConfig {
165    /// Enable SQLite-backed memory storage.
166    #[serde(default = "default_true")]
167    pub enabled: bool,
168    /// Path to the SQLite database file.
169    /// Empty string means default: `~/.oxios/workspace/memory.db`
170    #[serde(default)]
171    pub path: String,
172    /// Embedding vector dimension.
173    /// Controls the `vec0` virtual table dimension.
174    /// Common values: 128 (fast), 256 (balanced), 768 (full Gemma).
175    #[serde(default = "default_embedding_dim")]
176    pub embedding_dim: usize,
177    /// Enable WAL mode for concurrent reads.
178    #[serde(default = "default_true")]
179    pub wal_mode: bool,
180}
181
182fn default_embedding_dim() -> usize {
183    256
184}
185
186impl Default for SqliteMemoryConfig {
187    fn default() -> Self {
188        Self {
189            enabled: true,
190            path: String::new(),
191            embedding_dim: 256,
192            wal_mode: true,
193        }
194    }
195}
196
197// ---------------------------------------------------------------------------
198// EmbeddingConfig (RFC-012: Embedding Provider)
199// ---------------------------------------------------------------------------
200
201/// Embedding provider configuration (RFC-012).
202///
203/// Controls which embedding model is used for semantic search.
204/// When `embedding-mlx` feature is enabled and `provider = "mlx"`,
205/// uses EmbeddingGemma-300m via MLX on Apple Silicon.
206/// Otherwise falls back to TF-IDF.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct EmbeddingConfig {
209    /// Embedding provider: "tfidf" (default) or "mlx" (Apple Silicon).
210    #[serde(default = "default_embedding_provider")]
211    pub provider: String,
212    /// Matryoshka dimension: 128, 256, 512, or 768.
213    /// Only used when provider = "mlx".
214    #[serde(default = "default_embedding_dim")]
215    pub dimension: usize,
216    /// Model TTL in seconds. Unloaded after this duration of inactivity.
217    /// Only used when provider = "mlx".
218    #[serde(default = "default_model_ttl")]
219    pub model_ttl_secs: u64,
220}
221
222fn default_embedding_provider() -> String {
223    "gguf".to_string()
224}
225
226fn default_model_ttl() -> u64 {
227    300 // 5 minutes
228}
229
230impl Default for EmbeddingConfig {
231    fn default() -> Self {
232        Self {
233            provider: default_embedding_provider(),
234            dimension: default_embedding_dim(),
235            model_ttl_secs: default_model_ttl(),
236        }
237    }
238}
239
240// ---------------------------------------------------------------------------
241// LearningConfig (RFC-012 Phase 4: SONA)
242// ---------------------------------------------------------------------------
243
244/// Learning engine configuration (RFC-012 Phase 4).
245///
246/// Controls SONA self-learning persistence.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct LearningConfig {
249    /// Enable the learning subsystem (SONA).
250    #[serde(default = "default_true")]
251    pub enabled: bool,
252    /// SONA operating mode: "realtime", "balanced", "research", "edge".
253    #[serde(default = "default_sona_mode")]
254    pub sona_mode: String,
255    /// Interval between automatic distillation runs (hours).
256    #[serde(default = "default_distill_interval")]
257    pub distill_interval_hours: u64,
258    /// Minimum quality score for auto-promoting patterns to long-term.
259    #[serde(default = "default_auto_promote_quality")]
260    pub auto_promote_quality: f32,
261    /// Minimum usage count before auto-promotion is considered.
262    #[serde(default = "default_auto_promote_min_usage")]
263    pub auto_promote_min_usage: u32,
264}
265
266fn default_sona_mode() -> String {
267    "balanced".to_string()
268}
269
270fn default_distill_interval() -> u64 {
271    6
272}
273
274fn default_auto_promote_quality() -> f32 {
275    0.8
276}
277
278fn default_auto_promote_min_usage() -> u32 {
279    3
280}
281
282impl Default for LearningConfig {
283    fn default() -> Self {
284        Self {
285            enabled: true,
286            sona_mode: default_sona_mode(),
287            distill_interval_hours: default_distill_interval(),
288            auto_promote_quality: default_auto_promote_quality(),
289            auto_promote_min_usage: default_auto_promote_min_usage(),
290        }
291    }
292}
293
294// ---------------------------------------------------------------------------
295// MemoryBridgeConfig (RFC-012 Phase 7: SQLite ↔ MEMORY.md)
296// ---------------------------------------------------------------------------
297
298/// AutoMemoryBridge configuration (RFC-012 Phase 7).
299///
300/// Controls bidirectional sync between SQLite memory store
301/// and external MEMORY.md files.
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct MemoryBridgeConfig {
304    /// Enable bidirectional sync with MEMORY.md.
305    #[serde(default)]
306    pub sync_enabled: bool,
307    /// Sync interval in seconds.
308    #[serde(default = "default_bridge_interval")]
309    pub interval_secs: u64,
310}
311
312fn default_bridge_interval() -> u64 {
313    3600
314}
315
316impl Default for MemoryBridgeConfig {
317    fn default() -> Self {
318        Self {
319            sync_enabled: false,
320            interval_secs: default_bridge_interval(),
321        }
322    }
323}
324
325// ---------------------------------------------------------------------------
326// ConsolidationConfig (RFC-008: Memory Consolidation)
327// ---------------------------------------------------------------------------
328
329/// Memory consolidation configuration (RFC-008).
330/// All values have sensible defaults — users never need to configure these.
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct ConsolidationConfig {
333    /// Preset: "conservative" | "balanced" | "aggressive" | "custom".
334    /// When not "custom", all other fields are overridden by the preset values.
335    /// Call `apply_preset()` once during kernel init to resolve.
336    #[serde(default = "default_preset")]
337    pub preset: String,
338
339    // ── Dream Process ─────────────────────────────────
340    #[serde(default = "default_true")]
341    pub dream_enabled: bool,
342    #[serde(default = "default_dream_interval")]
343    pub dream_interval_hours: u64,
344    #[serde(default = "default_dream_min_sessions")]
345    pub dream_min_sessions: u32,
346
347    // ── Tier Budgets ──────────────────────────────────
348    #[serde(default = "default_hot_max")]
349    pub hot_max_entries: usize,
350    #[serde(default = "default_warm_max")]
351    pub warm_max_entries: usize,
352    #[serde(default = "default_cold_max")]
353    pub cold_max_entries: usize,
354    #[serde(default = "default_hot_token_budget")]
355    pub hot_token_budget: usize,
356
357    // ── Decay ─────────────────────────────────────────
358    #[serde(default = "default_true")]
359    pub decay_enabled: bool,
360    #[serde(default = "default_one")]
361    pub decay_multiplier: f32,
362    #[serde(default = "default_decay_threshold")]
363    pub decay_threshold: f32,
364    #[serde(default = "default_retention_days")]
365    pub retention_days: u32,
366
367    // ── Auto-Protection ───────────────────────────────
368    #[serde(default = "default_true")]
369    pub auto_protection: bool,
370    #[serde(default = "default_protection_low_access")]
371    pub protection_low_access: u32,
372    #[serde(default = "default_protection_medium_access")]
373    pub protection_medium_access: u32,
374    #[serde(default = "default_protection_high_access")]
375    pub protection_high_access: u32,
376    #[serde(default = "default_protection_medium_sessions")]
377    pub protection_medium_sessions: u32,
378    #[serde(default = "default_protection_high_sessions")]
379    pub protection_high_sessions: u32,
380
381    // ── Auto-Classification ───────────────────────────
382    #[serde(default = "default_true")]
383    pub auto_classification: bool,
384    #[serde(default = "default_type_promotion_threshold")]
385    pub type_promotion_repetitions: u32,
386
387    // ── Compaction ────────────────────────────────────
388    #[serde(default = "default_compaction_threshold")]
389    pub compaction_line_threshold: usize,
390    #[serde(default = "default_true")]
391    pub llm_compaction: bool,
392
393    // ── Dream LLM ──────────────────────────────────────
394    /// Optional model for Dream LLM operations (None = rule-based fallback).
395    #[serde(default)]
396    pub dream_model: Option<String>,
397
398    // ── Protection Demotion ────────────────────────────
399    #[serde(default = "default_true")]
400    pub protection_demotion_enabled: bool,
401    #[serde(default = "default_demotion_stale_days")]
402    pub protection_demotion_stale_days: u32,
403    #[serde(default = "default_demotion_max_step")]
404    pub protection_demotion_max_step: u32,
405
406    // ── Proactive Recall ──────────────────────────────
407    #[serde(default = "default_true")]
408    pub proactive_recall: bool,
409    #[serde(default = "default_proactive_limit")]
410    pub proactive_recall_limit: usize,
411    #[serde(default = "default_proactive_threshold")]
412    pub proactive_recall_threshold: f32,
413}
414
415fn default_dream_interval() -> u64 {
416    24
417}
418fn default_dream_min_sessions() -> u32 {
419    5
420}
421fn default_hot_max() -> usize {
422    50
423}
424fn default_warm_max() -> usize {
425    500
426}
427fn default_cold_max() -> usize {
428    10_000
429}
430fn default_hot_token_budget() -> usize {
431    3_000
432}
433fn default_one() -> f32 {
434    1.0
435}
436fn default_decay_threshold() -> f32 {
437    0.05
438}
439fn default_retention_days() -> u32 {
440    90
441}
442fn default_protection_low_access() -> u32 {
443    2
444}
445fn default_protection_medium_access() -> u32 {
446    3
447}
448fn default_protection_high_access() -> u32 {
449    5
450}
451fn default_protection_medium_sessions() -> u32 {
452    2
453}
454fn default_protection_high_sessions() -> u32 {
455    3
456}
457fn default_type_promotion_threshold() -> u32 {
458    3
459}
460fn default_compaction_threshold() -> usize {
461    200
462}
463fn default_proactive_limit() -> usize {
464    5
465}
466fn default_proactive_threshold() -> f32 {
467    0.6
468}
469fn default_demotion_stale_days() -> u32 {
470    30
471}
472fn default_demotion_max_step() -> u32 {
473    1
474}
475
476fn default_preset() -> String {
477    "balanced".into()
478}
479
480impl Default for ConsolidationConfig {
481    fn default() -> Self {
482        Self {
483            preset: default_preset(),
484            dream_enabled: true,
485            dream_interval_hours: 24,
486            dream_min_sessions: 5,
487            hot_max_entries: 50,
488            warm_max_entries: 500,
489            cold_max_entries: 10_000,
490            hot_token_budget: 3_000,
491            decay_enabled: true,
492            decay_multiplier: 1.0,
493            decay_threshold: 0.05,
494            retention_days: 90,
495            auto_protection: true,
496            protection_low_access: 2,
497            protection_medium_access: 3,
498            protection_high_access: 5,
499            protection_medium_sessions: 2,
500            protection_high_sessions: 3,
501            auto_classification: true,
502            type_promotion_repetitions: 3,
503            compaction_line_threshold: 200,
504            llm_compaction: true,
505            dream_model: None,
506            protection_demotion_enabled: true,
507            protection_demotion_stale_days: 30,
508            protection_demotion_max_step: 1,
509            proactive_recall: true,
510            proactive_recall_limit: 5,
511            proactive_recall_threshold: 0.6,
512        }
513    }
514}
515
516impl ConsolidationConfig {
517    /// Apply the preset to all fields.
518    /// Call once during kernel initialization.
519    /// When `preset` is "custom", individual fields are left untouched.
520    pub fn apply_preset(&mut self) {
521        let resolved = match self.preset.as_str() {
522            "conservative" => Self::conservative(),
523            "aggressive" => Self::aggressive(),
524            "custom" => return,
525            _ => Self::default(), // "balanced" 및 알 수 없는 값
526        };
527        *self = resolved;
528    }
529
530    /// Conservative preset: slow decay, long retention, larger capacities.
531    fn conservative() -> Self {
532        Self {
533            preset: "conservative".into(),
534            dream_enabled: true,
535            dream_interval_hours: 48,
536            dream_min_sessions: 10,
537            hot_max_entries: 100,
538            warm_max_entries: 1000,
539            cold_max_entries: 50_000,
540            hot_token_budget: 5_000,
541            decay_enabled: true,
542            decay_multiplier: 0.8,
543            decay_threshold: 0.05,
544            retention_days: 365,
545            auto_protection: true,
546            protection_low_access: 3,
547            protection_medium_access: 5,
548            protection_high_access: 10,
549            protection_medium_sessions: 3,
550            protection_high_sessions: 5,
551            auto_classification: true,
552            type_promotion_repetitions: 5,
553            compaction_line_threshold: 300,
554            llm_compaction: true,
555            dream_model: None,
556            protection_demotion_enabled: true,
557            protection_demotion_stale_days: 90,
558            protection_demotion_max_step: 1,
559            proactive_recall: true,
560            proactive_recall_limit: 8,
561            proactive_recall_threshold: 0.5,
562        }
563    }
564
565    /// Aggressive preset: fast decay, short retention, smaller capacities.
566    fn aggressive() -> Self {
567        Self {
568            preset: "aggressive".into(),
569            dream_enabled: true,
570            dream_interval_hours: 4,
571            dream_min_sessions: 2,
572            hot_max_entries: 20,
573            warm_max_entries: 100,
574            cold_max_entries: 1_000,
575            hot_token_budget: 2_000,
576            decay_enabled: true,
577            decay_multiplier: 1.0,
578            decay_threshold: 0.1,
579            retention_days: 30,
580            auto_protection: true,
581            protection_low_access: 1,
582            protection_medium_access: 2,
583            protection_high_access: 3,
584            protection_medium_sessions: 1,
585            protection_high_sessions: 2,
586            auto_classification: true,
587            type_promotion_repetitions: 2,
588            compaction_line_threshold: 150,
589            llm_compaction: true,
590            dream_model: None,
591            protection_demotion_enabled: true,
592            protection_demotion_stale_days: 14,
593            protection_demotion_max_step: 2,
594            proactive_recall: true,
595            proactive_recall_limit: 3,
596            proactive_recall_threshold: 0.7,
597        }
598    }
599}
600
601/// Channel activation configuration.
602#[derive(Debug, Clone, Deserialize, Serialize, Default)]
603pub struct ChannelsConfig {
604    /// List of channel names to activate on startup.
605    /// Channels are message-only interfaces (CLI, Telegram).
606    #[serde(default)]
607    pub enabled: Vec<String>,
608
609    /// Telegram-specific configuration.
610    #[serde(default)]
611    pub telegram: TelegramChannelConfig,
612}
613
614/// Surface activation configuration.
615///
616/// Surfaces are kernel-connected control interfaces (Web dashboard, future desktop apps).
617/// They have direct kernel access for management, monitoring, and configuration.
618#[derive(Debug, Clone, Deserialize, Serialize)]
619pub struct SurfacesConfig {
620    /// List of surface names to activate on startup.
621    /// Default: ["web"] if the web feature is compiled in.
622    #[serde(default = "default_surfaces_enabled")]
623    pub enabled: Vec<String>,
624}
625
626fn default_surfaces_enabled() -> Vec<String> {
627    vec!["web".to_string()]
628}
629
630impl Default for SurfacesConfig {
631    fn default() -> Self {
632        Self {
633            enabled: default_surfaces_enabled(),
634        }
635    }
636}
637
638/// Telegram channel configuration.
639#[derive(Debug, Clone, Deserialize, Serialize)]
640pub struct TelegramChannelConfig {
641    /// Environment variable name holding the bot token.
642    #[serde(default = "default_telegram_token_env")]
643    pub bot_token_env: String,
644    /// List of allowed Telegram user IDs (empty = allow all).
645    #[serde(default)]
646    pub allowed_users: Vec<i64>,
647    /// Telegram session management settings.
648    #[serde(default)]
649    pub session: TelegramSessionConfig,
650}
651
652fn default_telegram_token_env() -> String {
653    "TELEGRAM_BOT_TOKEN".to_string()
654}
655
656impl Default for TelegramChannelConfig {
657    fn default() -> Self {
658        Self {
659            bot_token_env: default_telegram_token_env(),
660            allowed_users: Vec::new(),
661            session: TelegramSessionConfig::default(),
662        }
663    }
664}
665
666/// LLM engine configuration.
667#[derive(Debug, Clone, Deserialize, Serialize)]
668#[allow(clippy::derivable_impls)]
669pub struct EngineConfig {
670    /// Default model in "provider/model" format.
671    /// Empty string means no model configured — onboarding required.
672    #[serde(default)]
673    pub default_model: String,
674    /// Explicit API key override (highest priority).
675    /// If empty/None, falls back to oxi auth store, then env vars.
676    /// Masked when serialized to API responses.
677    #[serde(default, skip_serializing)]
678    pub api_key: Option<String>,
679    /// Per-provider options for fine-grained control (thinking mode, etc.).
680    /// Passed through to `AgentLoopConfig::provider_options`.
681    #[serde(default)]
682    pub provider_options: Option<oxi_sdk::ProviderOptions>,
683    /// Enable complexity-based model routing.
684    /// When enabled, the engine can route simple tasks to cheaper models
685    /// and complex tasks to more capable ones.
686    #[serde(default)]
687    pub routing_enabled: bool,
688    /// Prefer cost-efficient models when routing.
689    #[serde(default)]
690    pub prefer_cost_efficient: bool,
691    /// Fallback models to try when the primary model fails.
692    #[serde(default)]
693    pub fallback_models: Vec<String>,
694    /// Models excluded from automatic routing.
695    #[serde(default)]
696    pub excluded_models: Vec<String>,
697}
698
699#[allow(clippy::derivable_impls)]
700impl Default for EngineConfig {
701    fn default() -> Self {
702        Self {
703            default_model: String::new(),
704            api_key: None,
705            provider_options: None,
706            routing_enabled: false,
707            prefer_cost_efficient: false,
708            fallback_models: Vec::new(),
709            excluded_models: Vec::new(),
710        }
711    }
712}
713
714/// Daemon mode configuration.
715#[derive(Debug, Clone, Deserialize, Serialize)]
716pub struct DaemonConfig {
717    /// PID file path.
718    #[serde(default = "default_pid_file")]
719    pub pid_file: String,
720    /// Log directory.
721    #[serde(default = "default_daemon_log_dir")]
722    pub log_dir: String,
723}
724
725fn default_pid_file() -> String {
726    dirs::home_dir()
727        .map(|h| format!("{}/.oxios/oxios.pid", h.display()))
728        .unwrap_or_else(|| "./oxios.pid".into())
729}
730
731fn default_daemon_log_dir() -> String {
732    dirs::home_dir()
733        .map(|h| format!("{}/.oxios/logs", h.display()))
734        .unwrap_or_else(|| "./logs".into())
735}
736
737impl Default for DaemonConfig {
738    fn default() -> Self {
739        Self {
740            pid_file: default_pid_file(),
741            log_dir: default_daemon_log_dir(),
742        }
743    }
744}
745
746/// Session management configuration.
747#[derive(Debug, Clone, Deserialize, Serialize)]
748pub struct SessionConfig {
749    /// Maximum number of sessions to retain.
750    /// When exceeded, oldest sessions (by `updated_at`) are pruned.
751    /// Set to 0 for unlimited.
752    #[serde(default = "default_max_sessions")]
753    pub max_sessions: usize,
754
755    /// Time-to-live for sessions in hours.
756    /// Sessions older than this are automatically pruned.
757    /// Set to 0 for unlimited (no TTL-based pruning).
758    #[serde(default = "default_session_ttl_hours")]
759    pub ttl_hours: u64,
760
761    /// Enable automatic session pruning on every session save.
762    #[serde(default = "default_true")]
763    pub auto_prune: bool,
764}
765
766fn default_max_sessions() -> usize {
767    100
768}
769
770fn default_session_ttl_hours() -> u64 {
771    168 // 7 days
772}
773
774impl Default for SessionConfig {
775    fn default() -> Self {
776        Self {
777            max_sessions: default_max_sessions(),
778            ttl_hours: default_session_ttl_hours(),
779            auto_prune: true,
780        }
781    }
782}
783
784/// Telegram session management configuration.
785#[derive(Debug, Clone, Deserialize, Serialize)]
786pub struct TelegramSessionConfig {
787    /// Automatically rotate to a new session after this many hours of inactivity.
788    /// Set to 0 to disable time-based rotation.
789    #[serde(default = "default_telegram_session_rotation_hours")]
790    pub rotation_hours: u64,
791
792    /// Maximum number of messages per session before auto-rotating.
793    /// Set to 0 for unlimited.
794    #[serde(default = "default_telegram_session_max_messages")]
795    pub max_messages: usize,
796}
797
798fn default_telegram_session_rotation_hours() -> u64 {
799    2 // 2 hours
800}
801
802fn default_telegram_session_max_messages() -> usize {
803    0 // unlimited by default
804}
805
806impl Default for TelegramSessionConfig {
807    fn default() -> Self {
808        Self {
809            rotation_hours: default_telegram_session_rotation_hours(),
810            max_messages: default_telegram_session_max_messages(),
811        }
812    }
813}
814
815/// Top-level Oxios configuration.
816#[derive(Debug, Clone, Deserialize, Serialize, Default)]
817pub struct OxiosConfig {
818    /// Kernel settings.
819    pub kernel: KernelConfig,
820    /// LLM engine settings.
821    #[serde(default)]
822    pub engine: EngineConfig,
823    /// Daemon mode settings.
824    #[serde(default)]
825    pub daemon: DaemonConfig,
826    /// Gateway settings.
827    #[serde(default)]
828    pub gateway: GatewayConfig,
829    /// Scheduler settings (AIOS-inspired task scheduling).
830    #[serde(default)]
831    pub scheduler: SchedulerConfig,
832    /// Orchestrator settings (Ouroboros protocol execution).
833    #[serde(default)]
834    pub orchestrator: OrchestratorConfig,
835    /// Context manager settings (LLM context window management).
836    #[serde(default)]
837    pub context: ContextConfig,
838    /// Security/access control settings.
839    #[serde(default)]
840    pub security: SecurityConfig,
841    /// Persona system settings.
842    #[serde(default)]
843    pub persona: PersonaConfig,
844    /// Memory system settings.
845    #[serde(default)]
846    pub memory: MemoryConfig,
847    /// Cron scheduler settings.
848    #[serde(default)]
849    pub cron: CronConfig,
850    /// MCP server configurations.
851    #[serde(default)]
852    pub mcp: McpConfig,
853    /// Git version control settings.
854    #[serde(default)]
855    pub git: GitConfig,
856    /// Audit trail configuration.
857    #[serde(default)]
858    pub audit: AuditConfig,
859    /// Budget enforcement configuration.
860    #[serde(default)]
861    pub budget: BudgetConfig,
862    /// Exec configuration (host command execution bridge).
863    #[serde(default)]
864    pub exec: ExecConfig,
865    /// Resource monitor configuration.
866    #[serde(default)]
867    pub resource_monitor: ResourceMonitorConfig,
868    /// OpenTelemetry tracing configuration.
869    #[serde(default)]
870    pub otel: OtelConfig,
871    /// Logging configuration.
872    #[serde(default)]
873    pub logging: LoggingConfig,
874    /// Channel activation configuration (message interfaces: CLI, Telegram).
875    #[serde(default)]
876    pub channels: ChannelsConfig,
877    /// Surface activation configuration (control interfaces: Web dashboard).
878    #[serde(default)]
879    pub surfaces: Option<SurfacesConfig>,
880    /// Headless browser configuration.
881    #[serde(default)]
882    pub browser: BrowserConfig,
883    /// Session management configuration.
884    #[serde(default)]
885    pub session: SessionConfig,
886    /// ClawHub marketplace configuration.
887    #[serde(default)]
888    pub marketplace: MarketplaceConfig,
889    /// Calendar configuration.
890    #[serde(default)]
891    pub calendar: CalendarConfig,
892    /// Email configuration.
893    #[serde(default)]
894    pub email: EmailConfig,
895}
896
897/// Kernel configuration.
898#[derive(Debug, Clone, Deserialize, Serialize)]
899pub struct KernelConfig {
900    /// Path to the workspace directory.
901    #[serde(default = "default_workspace")]
902    pub workspace: String,
903    /// Broadcast capacity for the event bus.
904    #[serde(default = "default_event_bus_capacity")]
905    pub event_bus_capacity: usize,
906    /// Maximum number of concurrent agents.
907    #[serde(default = "default_max_agents")]
908    pub max_agents: usize,
909}
910
911fn default_workspace() -> String {
912    dirs_home().unwrap_or_else(|| ".".into())
913}
914
915fn dirs_home() -> Option<String> {
916    dirs::home_dir().map(|h| format!("{}/.oxios/workspace", h.display()))
917}
918
919fn default_event_bus_capacity() -> usize {
920    256
921}
922
923fn default_max_agents() -> usize {
924    10
925}
926
927impl Default for KernelConfig {
928    fn default() -> Self {
929        Self {
930            workspace: default_workspace(),
931            event_bus_capacity: default_event_bus_capacity(),
932            max_agents: 10,
933        }
934    }
935}
936
937/// Gateway configuration.
938#[derive(Debug, Clone, Deserialize, Serialize)]
939pub struct GatewayConfig {
940    /// Host to bind the gateway to.
941    #[serde(default = "default_gateway_host")]
942    pub host: String,
943    /// Port for the gateway server.
944    #[serde(default = "default_gateway_port")]
945    pub port: u16,
946    /// Expose `/api-docs` (Swagger UI) and `/openapi.json`.
947    ///
948    /// For safety this is gated to localhost-only binds (127.0.0.0/8, ::1,
949    /// "localhost"). Setting this to `true` while binding to a public address
950    /// is a no-op. Default: `false`.
951    ///
952    /// Why: Swagger UI + the full OpenAPI schema expand the attack surface
953    /// (route discovery, parameter names, security scheme details). Local
954    /// dev typically wants them; production typically does not.
955    #[serde(default)]
956    pub expose_api_docs: bool,
957}
958
959impl GatewayConfig {
960    /// Whether the gateway may expose `/api-docs` and `/openapi.json`.
961    ///
962    /// Returns `true` only when both:
963    /// - `expose_api_docs` is explicitly enabled, AND
964    /// - the bind address is a loopback address.
965    pub fn should_expose_api_docs(&self) -> bool {
966        if !self.expose_api_docs {
967            return false;
968        }
969        let h = self.host.trim();
970        h == "127.0.0.1" || h == "::1" || h == "localhost" || h.starts_with("127.")
971    }
972}
973
974/// ClawHub marketplace configuration.
975#[derive(Debug, Clone, Deserialize, Serialize)]
976pub struct MarketplaceConfig {
977    /// Base URL for the ClawHub registry.
978    /// Defaults to `https://clawhub.ai`.
979    #[serde(default)]
980    pub base_url: Option<String>,
981    /// Whether the marketplace is enabled.
982    #[serde(default = "default_true")]
983    pub enabled: bool,
984    /// Skills.sh (Vercel Labs ecosystem) configuration.
985    #[serde(default)]
986    pub skills_sh: SkillsShConfig,
987}
988
989/// Skills.sh registry configuration.
990#[derive(Debug, Clone, Deserialize, Serialize)]
991pub struct SkillsShConfig {
992    /// Base URL for the Skills.sh API.
993    /// Defaults to `https://skills.sh`.
994    #[serde(default)]
995    pub base_url: Option<String>,
996    /// API key for Skills.sh authentication.
997    /// Falls back to `SKILLS_SH_TOKEN` env var if not set.
998    #[serde(default)]
999    pub api_key: Option<String>,
1000    /// Whether Skills.sh integration is enabled.
1001    #[serde(default = "default_true")]
1002    pub enabled: bool,
1003}
1004
1005impl Default for MarketplaceConfig {
1006    fn default() -> Self {
1007        Self {
1008            base_url: Some("https://clawhub.ai".to_string()),
1009            enabled: true,
1010            skills_sh: SkillsShConfig::default(),
1011        }
1012    }
1013}
1014
1015impl Default for SkillsShConfig {
1016    fn default() -> Self {
1017        Self {
1018            base_url: None,
1019            api_key: None,
1020            enabled: true,
1021        }
1022    }
1023}
1024
1025/// Calendar configuration.
1026#[derive(Debug, Clone, Deserialize, Serialize)]
1027pub struct CalendarConfig {
1028    /// Enable the calendar system.
1029    #[serde(default)]
1030    pub enabled: bool,
1031    /// Default timezone for events.
1032    #[serde(default = "default_calendar_timezone")]
1033    pub timezone: String,
1034    /// Default reminder minutes for new events.
1035    #[serde(default = "default_reminder_minutes")]
1036    pub default_reminder_minutes: Vec<u32>,
1037    /// Alarm dispatch channels.
1038    #[serde(default)]
1039    pub alarm_channels: Vec<String>,
1040    /// Journal sync mode: "on_open", "midnight", "both".
1041    #[serde(default = "default_journal_sync")]
1042    pub journal_sync: String,
1043    /// Show cron jobs on the calendar.
1044    #[serde(default = "default_true")]
1045    pub system_calendar: bool,
1046    /// Days after which old events are archived.
1047    #[serde(default = "default_archive_days")]
1048    pub archive_after_days: u32,
1049}
1050
1051fn default_calendar_timezone() -> String {
1052    "Asia/Seoul".to_string()
1053}
1054
1055fn default_reminder_minutes() -> Vec<u32> {
1056    vec![15]
1057}
1058
1059fn default_journal_sync() -> String {
1060    "on_open".to_string()
1061}
1062
1063fn default_archive_days() -> u32 {
1064    365
1065}
1066
1067impl Default for CalendarConfig {
1068    fn default() -> Self {
1069        Self {
1070            enabled: false,
1071            timezone: default_calendar_timezone(),
1072            default_reminder_minutes: default_reminder_minutes(),
1073            alarm_channels: vec![],
1074            journal_sync: default_journal_sync(),
1075            system_calendar: true,
1076            archive_after_days: default_archive_days(),
1077        }
1078    }
1079}
1080
1081/// Email configuration.
1082///
1083/// Controls SMTP email sending. When enabled, agents gain the `send_email` tool.
1084/// v1 sends to the user's own email only.
1085#[derive(Debug, Clone, Deserialize, Serialize)]
1086pub struct EmailConfig {
1087    /// Enable the email system.
1088    #[serde(default)]
1089    pub enabled: bool,
1090    /// The user's email address (used as both sender and default recipient).
1091    #[serde(default)]
1092    pub my_email: String,
1093    /// SMTP provider preset ("gmail", "icloud", "fastmail", "custom").
1094    #[serde(default = "default_email_provider")]
1095    pub provider: SmtpProvider,
1096    /// SMTP host (auto-filled from provider if empty).
1097    #[serde(default)]
1098    pub host: String,
1099    /// SMTP port (auto-filled from provider if 0).
1100    #[serde(default)]
1101    pub port: u16,
1102    /// TLS mode (auto-filled from provider if None).
1103    #[serde(default)]
1104    pub tls: Option<SmtpTls>,
1105    /// SMTP auth username (defaults to `my_email` if empty).
1106    #[serde(default)]
1107    pub user: String,
1108    /// Credential store key for the SMTP password.
1109    /// Falls back to `OXIOS_EMAIL_PASSWORD` env var.
1110    #[serde(default = "default_email_secret_ref")]
1111    pub secret_ref: String,
1112    /// Maximum emails per hour (rate limit, default: 10).
1113    #[serde(default = "default_rate_limit_emails")]
1114    pub rate_limit_per_hour: usize,
1115}
1116
1117fn default_email_provider() -> SmtpProvider {
1118    SmtpProvider::Gmail
1119}
1120
1121fn default_email_secret_ref() -> String {
1122    "email_smtp".to_string()
1123}
1124
1125fn default_rate_limit_emails() -> usize {
1126    10
1127}
1128
1129impl Default for EmailConfig {
1130    fn default() -> Self {
1131        Self {
1132            enabled: false,
1133            my_email: String::new(),
1134            provider: default_email_provider(),
1135            host: String::new(),
1136            port: 0,
1137            tls: None,
1138            user: String::new(),
1139            secret_ref: default_email_secret_ref(),
1140            rate_limit_per_hour: default_rate_limit_emails(),
1141        }
1142    }
1143}
1144
1145impl EmailConfig {
1146    /// Resolve the effective provider, falling back to Gmail.
1147    pub fn provider(&self) -> SmtpProvider {
1148        self.provider
1149    }
1150}
1151
1152fn default_gateway_host() -> String {
1153    "127.0.0.1".into()
1154}
1155
1156fn default_gateway_port() -> u16 {
1157    4200
1158}
1159
1160impl Default for GatewayConfig {
1161    fn default() -> Self {
1162        Self {
1163            host: default_gateway_host(),
1164            port: default_gateway_port(),
1165            expose_api_docs: false,
1166        }
1167    }
1168}
1169
1170/// Execution mode for commands.
1171///
1172/// - `Structured`: Binary allowlist + metacharacter blocking (recommended)
1173/// - `Shell`: Raw bash execution (dangerous, requires `allow_shell_mode=true`)
1174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1175#[serde(rename_all = "lowercase")]
1176pub enum ExecMode {
1177    /// Structured binary execution with allowlist and metacharacter blocking.
1178    #[default]
1179    Structured,
1180    /// Shell execution via `bash -c`. DANGEROUS — requires explicit enable.
1181    Shell,
1182}
1183
1184/// Execution allowlist behavior mode.
1185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1186#[serde(rename_all = "snake_case")]
1187#[derive(Default)]
1188pub enum AllowlistMode {
1189    /// All binaries are permitted (development only).
1190    Permissive,
1191    /// Only binaries in `allowed_commands` may execute.
1192    #[default]
1193    Enforced,
1194}
1195
1196/// Exec configuration.
1197///
1198/// Governs how the kernel dispatches commands for execution.
1199#[derive(Debug, Clone, Deserialize, Serialize)]
1200pub struct ExecConfig {
1201    /// Default execution mode.
1202    #[serde(default)]
1203    pub default_mode: ExecMode,
1204    /// Allow shell mode. DANGEROUS — should be false in production.
1205    #[serde(default = "default_false")]
1206    pub allow_shell_mode: bool,
1207    /// Commands allowed to run on the host.
1208    /// If empty, *all* bare-name commands are permitted (development mode).
1209    #[serde(default)]
1210    pub allowed_commands: Vec<String>,
1211    /// Allowlist enforcement mode.
1212    /// `Permissive` = empty list means all allowed (dev mode).
1213    /// `Enforced` = only listed commands allowed (production).
1214    #[serde(default)]
1215    pub allowlist_mode: AllowlistMode,
1216    /// Default timeout for an exec call in seconds.
1217    #[serde(default = "default_exec_timeout")]
1218    pub default_timeout_secs: u64,
1219    /// Maximum allowed timeout for an exec call in seconds.
1220    #[serde(default = "default_exec_max_timeout")]
1221    pub max_timeout_secs: u64,
1222}
1223
1224fn default_false() -> bool {
1225    false
1226}
1227
1228fn default_exec_timeout() -> u64 {
1229    120
1230}
1231
1232fn default_exec_max_timeout() -> u64 {
1233    600
1234}
1235
1236impl ExecConfig {
1237    /// Check whether a binary / command name is allowed to execute.
1238    ///
1239    /// In `Permissive` mode, returns `true` when `allowed_commands` is empty
1240    /// (all allowed) **or** when the name is present in the allow-list.
1241    ///
1242    /// In `Enforced` mode, only names present in the allow-list are permitted.
1243    pub fn is_binary_allowed(&self, name: &str) -> bool {
1244        match self.allowlist_mode {
1245            AllowlistMode::Permissive => {
1246                self.allowed_commands.is_empty() || self.allowed_commands.iter().any(|c| c == name)
1247            }
1248            AllowlistMode::Enforced => self.allowed_commands.iter().any(|c| c == name),
1249        }
1250    }
1251}
1252
1253impl Default for ExecConfig {
1254    fn default() -> Self {
1255        Self {
1256            default_mode: ExecMode::default(),
1257            allow_shell_mode: default_false(),
1258            allowed_commands: Vec::new(),
1259            allowlist_mode: AllowlistMode::default(),
1260            default_timeout_secs: default_exec_timeout(),
1261            max_timeout_secs: default_exec_max_timeout(),
1262        }
1263    }
1264}
1265
1266/// Scheduler configuration (inspired by AIOS / AgentRM).
1267#[derive(Debug, Clone, Deserialize, Serialize)]
1268pub struct SchedulerConfig {
1269    /// Maximum number of concurrent agent tasks.
1270    #[serde(default = "default_max_concurrent")]
1271    pub max_concurrent: usize,
1272    /// Maximum LLM API calls per minute (rate limiting).
1273    #[serde(default = "default_rate_limit")]
1274    pub rate_limit_per_minute: u32,
1275    /// Timeout in seconds before a running task is considered a zombie.
1276    #[serde(default = "default_zombie_timeout")]
1277    pub zombie_timeout_secs: u64,
1278}
1279
1280fn default_max_concurrent() -> usize {
1281    5
1282}
1283
1284fn default_rate_limit() -> u32 {
1285    60
1286}
1287
1288fn default_zombie_timeout() -> u64 {
1289    300
1290}
1291
1292impl Default for SchedulerConfig {
1293    fn default() -> Self {
1294        Self {
1295            max_concurrent: default_max_concurrent(),
1296            rate_limit_per_minute: default_rate_limit(),
1297            zombie_timeout_secs: default_zombie_timeout(),
1298        }
1299    }
1300}
1301
1302/// Orchestrator configuration (Ouroboros protocol execution).
1303#[derive(Debug, Clone, Deserialize, Serialize)]
1304pub struct OrchestratorConfig {
1305    /// Maximum evolution iterations (0 = evaluate only, no evolution).
1306    /// Default: 3.
1307    #[serde(default = "default_max_evolution_iterations")]
1308    pub max_evolution_iterations: u32,
1309
1310    /// Minimum evaluation score for task to be considered passed (0.0–1.0).
1311    /// Default: 0.8.
1312    #[serde(default = "default_min_evaluation_score")]
1313    pub min_evaluation_score: f64,
1314
1315    /// Enable evaluation result caching.
1316    #[serde(default = "default_true")]
1317    pub eval_cache_enabled: bool,
1318}
1319
1320fn default_max_evolution_iterations() -> u32 {
1321    3
1322}
1323
1324fn default_min_evaluation_score() -> f64 {
1325    0.8
1326}
1327
1328impl Default for OrchestratorConfig {
1329    fn default() -> Self {
1330        Self {
1331            max_evolution_iterations: default_max_evolution_iterations(),
1332            min_evaluation_score: default_min_evaluation_score(),
1333            eval_cache_enabled: true,
1334        }
1335    }
1336}
1337
1338/// Context manager configuration (inspired by AIOS).
1339#[derive(Debug, Clone, Deserialize, Serialize)]
1340pub struct ContextConfig {
1341    /// Maximum tokens in the active (in-context) tier.
1342    #[serde(default = "default_active_limit")]
1343    pub active_limit_tokens: usize,
1344    /// Maximum entries in the cache tier.
1345    #[serde(default = "default_cache_limit")]
1346    pub cache_limit_entries: usize,
1347}
1348
1349fn default_active_limit() -> usize {
1350    100_000
1351}
1352
1353fn default_cache_limit() -> usize {
1354    50
1355}
1356
1357impl Default for ContextConfig {
1358    fn default() -> Self {
1359        Self {
1360            active_limit_tokens: default_active_limit(),
1361            cache_limit_entries: default_cache_limit(),
1362        }
1363    }
1364}
1365
1366/// Security/access control configuration (inspired by OWASP Agentic AI).
1367#[derive(Debug, Clone, Deserialize, Serialize)]
1368pub struct SecurityConfig {
1369    /// Default allowed tools for agents (least privilege).
1370    #[serde(default = "default_allowed_tools")]
1371    pub allowed_tools: Vec<String>,
1372    /// Whether agents can make network requests by default.
1373    #[serde(default)]
1374    pub network_access: bool,
1375    /// Maximum execution time in seconds for agent tasks.
1376    #[serde(default = "default_max_exec_time")]
1377    pub max_execution_time_secs: u64,
1378    /// Maximum memory in MB for agent tasks.
1379    #[serde(default = "default_max_memory")]
1380    pub max_memory_mb: u64,
1381    /// Whether agents can fork sub-agents by default.
1382    #[serde(default)]
1383    pub can_fork: bool,
1384    /// Maximum audit log entries to retain.
1385    #[serde(default = "default_max_audit")]
1386    pub max_audit_entries: usize,
1387    /// Enable API key authentication.
1388    #[serde(default)]
1389    pub auth_enabled: bool,
1390    /// Allowed CORS origins.
1391    #[serde(default = "default_cors_origins")]
1392    pub cors_origins: Vec<String>,
1393    /// Path for audit log file (optional, enables file-based persistence).
1394    #[serde(default)]
1395    pub audit_log_path: Option<String>,
1396    /// Rate limit for API endpoints (requests per minute).
1397    #[serde(default = "default_rate_limit_per_minute")]
1398    pub rate_limit_per_minute: u32,
1399}
1400
1401fn default_allowed_tools() -> Vec<String> {
1402    vec![
1403        "read".to_string(),
1404        "write".to_string(),
1405        "edit".to_string(),
1406        "bash".to_string(),
1407        "grep".to_string(),
1408        "find".to_string(),
1409        "exec".to_string(),
1410    ]
1411}
1412
1413fn default_max_exec_time() -> u64 {
1414    300
1415}
1416
1417fn default_max_memory() -> u64 {
1418    512
1419}
1420
1421fn default_max_audit() -> usize {
1422    10_000
1423}
1424
1425fn default_rate_limit_per_minute() -> u32 {
1426    120
1427}
1428
1429fn default_cors_origins() -> Vec<String> {
1430    vec!["http://localhost:4200".to_string()]
1431}
1432
1433impl Default for SecurityConfig {
1434    fn default() -> Self {
1435        Self {
1436            allowed_tools: default_allowed_tools(),
1437            network_access: false,
1438            max_execution_time_secs: default_max_exec_time(),
1439            max_memory_mb: default_max_memory(),
1440            can_fork: false,
1441            max_audit_entries: default_max_audit(),
1442            auth_enabled: false,
1443            cors_origins: default_cors_origins(),
1444            audit_log_path: None,
1445            rate_limit_per_minute: default_rate_limit_per_minute(),
1446        }
1447    }
1448}
1449
1450/// Persona system configuration.
1451#[derive(Debug, Clone, Deserialize, Serialize)]
1452pub struct PersonaConfig {
1453    /// Default persona ID to activate on startup.
1454    #[serde(default)]
1455    pub default_persona_id: Option<String>,
1456    /// Maximum concurrent personas.
1457    #[serde(default = "default_max_concurrent_personas")]
1458    pub max_concurrent_personas: usize,
1459}
1460
1461fn default_max_concurrent_personas() -> usize {
1462    5
1463}
1464
1465impl Default for PersonaConfig {
1466    fn default() -> Self {
1467        Self {
1468            default_persona_id: Some("dev".to_string()),
1469            max_concurrent_personas: default_max_concurrent_personas(),
1470        }
1471    }
1472}
1473
1474/// MCP server configuration loaded from config.toml.
1475///
1476/// Each key is a server name; the value is a table with:
1477/// - `command`: executable to run (e.g. "npx", "python")
1478/// - `args`: arguments array
1479/// - `env`: optional map of environment variables
1480/// - `enabled`: whether to start this server on boot (default: true)
1481#[derive(Debug, Clone, Deserialize, Serialize, Default)]
1482pub struct McpConfig {
1483    /// Map of server-name → server definition.
1484    #[serde(default)]
1485    pub servers: std::collections::HashMap<String, McpServerDef>,
1486}
1487
1488/// A single MCP server definition in config.toml.
1489#[derive(Debug, Clone, Deserialize, Serialize)]
1490pub struct McpServerDef {
1491    /// Command to execute.
1492    pub command: String,
1493    /// Arguments passed to the command.
1494    #[serde(default)]
1495    pub args: Vec<String>,
1496    /// Environment variables.
1497    #[serde(default)]
1498    pub env: std::collections::HashMap<String, String>,
1499    /// Whether this server is enabled (default: true).
1500    #[serde(default = "default_mcp_enabled")]
1501    pub enabled: bool,
1502}
1503
1504fn default_mcp_enabled() -> bool {
1505    true
1506}
1507
1508/// Git version control configuration.
1509#[derive(Debug, Clone, Deserialize, Serialize)]
1510pub struct GitConfig {
1511    /// Enable automatic commits for state changes.
1512    #[serde(default = "default_true")]
1513    pub auto_commit: bool,
1514}
1515
1516impl Default for GitConfig {
1517    fn default() -> Self {
1518        Self { auto_commit: true }
1519    }
1520}
1521
1522/// Audit trail configuration.
1523#[derive(Debug, Clone, Deserialize, Serialize)]
1524pub struct AuditConfig {
1525    /// Maximum audit entries before pruning.
1526    #[serde(default = "default_audit_max_entries")]
1527    pub max_entries: usize,
1528    /// Enable audit trail.
1529    #[serde(default = "default_true")]
1530    pub enabled: bool,
1531}
1532
1533fn default_audit_max_entries() -> usize {
1534    100_000
1535}
1536
1537impl Default for AuditConfig {
1538    fn default() -> Self {
1539        Self {
1540            max_entries: default_audit_max_entries(),
1541            enabled: true,
1542        }
1543    }
1544}
1545
1546/// Budget enforcement configuration.
1547#[derive(Debug, Clone, Deserialize, Serialize)]
1548pub struct BudgetConfig {
1549    /// Default token budget per agent (0 = unlimited).
1550    #[serde(default)]
1551    pub default_token_budget: u64,
1552    /// Default call budget per agent (0 = unlimited).
1553    #[serde(default)]
1554    pub default_calls_budget: u64,
1555    /// Default budget window in seconds.
1556    #[serde(default = "default_budget_window")]
1557    pub default_window_secs: u64,
1558    /// Enable budget enforcement.
1559    #[serde(default = "default_true")]
1560    pub enabled: bool,
1561}
1562
1563fn default_budget_window() -> u64 {
1564    3600
1565}
1566
1567impl Default for BudgetConfig {
1568    fn default() -> Self {
1569        Self {
1570            default_token_budget: 0,
1571            default_calls_budget: 0,
1572            default_window_secs: default_budget_window(),
1573            enabled: true,
1574        }
1575    }
1576}
1577
1578/// Resource monitor configuration.
1579#[derive(Debug, Clone, Deserialize, Serialize)]
1580pub struct ResourceMonitorConfig {
1581    /// Snapshot interval in seconds.
1582    #[serde(default = "default_rm_interval")]
1583    pub interval_secs: u64,
1584    /// Maximum history entries.
1585    #[serde(default = "default_rm_history_max")]
1586    pub history_max: usize,
1587    /// CPU threshold for overload.
1588    #[serde(default = "default_rm_cpu_threshold")]
1589    pub cpu_threshold: f32,
1590    /// Memory threshold for overload (percentage).
1591    #[serde(default = "default_rm_mem_threshold")]
1592    pub memory_threshold: f32,
1593    /// Load average threshold for overload.
1594    #[serde(default = "default_rm_load_threshold")]
1595    pub load_threshold: f32,
1596}
1597
1598fn default_rm_interval() -> u64 {
1599    60
1600}
1601
1602fn default_rm_history_max() -> usize {
1603    60
1604}
1605
1606fn default_rm_cpu_threshold() -> f32 {
1607    90.0
1608}
1609
1610fn default_rm_mem_threshold() -> f32 {
1611    90.0
1612}
1613
1614fn default_rm_load_threshold() -> f32 {
1615    8.0
1616}
1617
1618impl Default for ResourceMonitorConfig {
1619    fn default() -> Self {
1620        Self {
1621            interval_secs: default_rm_interval(),
1622            history_max: default_rm_history_max(),
1623            cpu_threshold: default_rm_cpu_threshold(),
1624            memory_threshold: default_rm_mem_threshold(),
1625            load_threshold: default_rm_load_threshold(),
1626        }
1627    }
1628}
1629
1630/// OpenTelemetry tracing configuration.
1631#[derive(Debug, Clone, Deserialize, Serialize)]
1632pub struct OtelConfig {
1633    /// Enable OTLP export (default: false).
1634    #[serde(default)]
1635    pub enabled: bool,
1636    /// OTLP gRPC endpoint.
1637    #[serde(default = "default_otel_endpoint")]
1638    pub endpoint: String,
1639    /// Service name for traces.
1640    #[serde(default = "default_otel_service_name")]
1641    pub service_name: String,
1642    /// Sampling ratio (0.0 to 1.0).
1643    #[serde(default = "default_otel_sampling_ratio")]
1644    pub sampling_ratio: f64,
1645}
1646
1647fn default_otel_endpoint() -> String {
1648    "http://localhost:4317".into()
1649}
1650
1651fn default_otel_service_name() -> String {
1652    "oxios".into()
1653}
1654
1655fn default_otel_sampling_ratio() -> f64 {
1656    1.0
1657}
1658
1659impl Default for OtelConfig {
1660    fn default() -> Self {
1661        Self {
1662            enabled: false,
1663            endpoint: default_otel_endpoint(),
1664            service_name: default_otel_service_name(),
1665            sampling_ratio: default_otel_sampling_ratio(),
1666        }
1667    }
1668}
1669
1670/// Logging configuration.
1671#[derive(Debug, Clone, Deserialize, Serialize)]
1672pub struct LoggingConfig {
1673    /// Log format: "pretty", "json", or "compact".
1674    #[serde(default = "default_log_format")]
1675    pub format: String,
1676    /// Log level override (e.g. "info", "debug"). Falls back to RUST_LOG env var.
1677    #[serde(default)]
1678    pub level: Option<String>,
1679}
1680
1681fn default_log_format() -> String {
1682    "pretty".into()
1683}
1684
1685impl Default for LoggingConfig {
1686    fn default() -> Self {
1687        Self {
1688            format: default_log_format(),
1689            level: None,
1690        }
1691    }
1692}
1693
1694/// Headless browser configuration.
1695///
1696/// Engine configuration. Passes through to `oxi-sdk` browser tools.
1697/// with an `enabled` toggle. The engine config is passed through directly
1698/// to the browser — no field-by-field duplication.
1699#[derive(Debug, Clone, Deserialize, Serialize)]
1700pub struct BrowserConfig {
1701    /// Enable the browser integration.
1702    #[serde(default = "default_browser_enabled")]
1703    pub enabled: bool,
1704
1705    /// Engine configuration — passed to oxi-sdk's `native_browser_tools_with_config()`.
1706    ///
1707    /// All fields have sensible defaults; override only what you need:
1708    ///
1709    /// ```toml
1710    /// [browser.engine]
1711    /// user_agent = "MyBot/1.0"
1712    /// obey_robots = false
1713    /// js_timeout_ms = 10000
1714    /// ```
1715    #[serde(default)]
1716    pub engine: serde_json::Value,
1717}
1718
1719fn default_browser_enabled() -> bool {
1720    true
1721}
1722
1723impl Default for BrowserConfig {
1724    fn default() -> Self {
1725        Self {
1726            enabled: true,
1727            engine: serde_json::json!({}),
1728        }
1729    }
1730}
1731
1732/// Loads configuration from a TOML file.
1733pub fn load_config(path: &std::path::Path) -> anyhow::Result<OxiosConfig> {
1734    let content = std::fs::read_to_string(path)?;
1735    let config: OxiosConfig = toml::from_str(&content)?;
1736    let (errors, warnings) = config.validate();
1737    for w in warnings {
1738        tracing::warn!("config: {}", w);
1739    }
1740    if !errors.is_empty() {
1741        let msg = errors.join("; ");
1742        anyhow::bail!("Configuration validation failed: {msg}");
1743    }
1744    Ok(config)
1745}
1746
1747impl OxiosConfig {
1748    /// Returns the effective API key from the engine config.
1749    pub fn api_key(&self) -> Option<String> {
1750        self.engine.api_key.clone().filter(|k| !k.is_empty())
1751    }
1752
1753    /// Validate configuration values and return a list of warnings.
1754    /// Returns (errors, warnings). Empty errors = valid config.
1755    pub fn validate(&self) -> (Vec<String>, Vec<String>) {
1756        let mut errors = Vec::new();
1757        let mut warnings = Vec::new();
1758
1759        // Kernel validation
1760        if self.kernel.max_agents == 0 {
1761            errors.push("kernel.max_agents must be > 0".into());
1762        }
1763        if self.kernel.workspace.is_empty() {
1764            errors.push("kernel.workspace must not be empty".into());
1765        }
1766
1767        // Gateway validation
1768        if self.gateway.port == 0 {
1769            errors.push("gateway.port must be > 0".into());
1770        }
1771        if self.gateway.port < 1024 && self.gateway.host == "0.0.0.0" {
1772            warnings.push("Running on port <1024 as 0.0.0.0 may require root".into());
1773        }
1774
1775        // Scheduler validation
1776        if self.scheduler.max_concurrent == 0 {
1777            warnings.push("scheduler.max_concurrent is 0 — no tasks will run".into());
1778        }
1779        if self.scheduler.zombie_timeout_secs == 0 {
1780            errors.push("scheduler.zombie_timeout_secs must be > 0".into());
1781        }
1782
1783        // Cron validation
1784        for (name, job) in &self.cron.jobs {
1785            if job.schedule.is_empty() {
1786                errors.push(format!("cron.jobs.{name}: schedule is empty"));
1787            } else {
1788                // Normalize 5-field to 6-field (prepend "0 " for seconds)
1789                let normalized = {
1790                    let fields: Vec<&str> = job.schedule.split_whitespace().collect();
1791                    match fields.len() {
1792                        5 => format!("0 {}", job.schedule),
1793                        _ => job.schedule.clone(),
1794                    }
1795                };
1796                if Schedule::from_str(&normalized).is_err() {
1797                    errors.push(format!(
1798                        "cron.jobs.{}: invalid cron expression '{}'",
1799                        name, job.schedule
1800                    ));
1801                }
1802            }
1803            if job.goal.is_empty() {
1804                errors.push(format!("cron.jobs.{name}: goal is empty"));
1805            }
1806        }
1807
1808        // Security validation
1809        if self.security.max_execution_time_secs == 0 {
1810            warnings.push("security.max_execution_time_secs is 0 — no timeout".into());
1811        }
1812
1813        // Audit validation
1814        if self.audit.max_entries == 0 {
1815            warnings.push("audit.max_entries is 0 — audit will never prune".into());
1816        }
1817
1818        // Budget validation
1819        if self.budget.default_window_secs == 0 {
1820            warnings.push("budget.default_window_secs is 0 — no time window".into());
1821        }
1822
1823        // Session validation
1824        if self.session.max_sessions == 0 && self.session.ttl_hours == 0 && self.session.auto_prune
1825        {
1826            warnings.push("session: auto_prune is enabled but both max_sessions and ttl_hours are 0 — nothing will be pruned".into());
1827        }
1828
1829        // Exec validation
1830        if self.exec.default_timeout_secs == 0 {
1831            errors.push("exec.default_timeout_secs must be > 0".into());
1832        }
1833        if self.exec.max_timeout_secs == 0 {
1834            errors.push("exec.max_timeout_secs must be > 0".into());
1835        }
1836        if self.exec.default_timeout_secs > self.exec.max_timeout_secs {
1837            errors.push(format!(
1838                "exec.default_timeout_secs ({}) must not exceed max_timeout_secs ({})",
1839                self.exec.default_timeout_secs, self.exec.max_timeout_secs
1840            ));
1841        }
1842
1843        // Resource monitor validation
1844        if self.resource_monitor.cpu_threshold > 100.0 {
1845            errors.push("resource_monitor.cpu_threshold must be <= 100".into());
1846        }
1847        if self.resource_monitor.memory_threshold > 100.0 {
1848            errors.push("resource_monitor.memory_threshold must be <= 100".into());
1849        }
1850
1851        // Channels validation (message interfaces only)
1852        for name in &self.channels.enabled {
1853            let valid = ["cli", "telegram"];
1854            if !valid.contains(&name.as_str()) {
1855                warnings.push(format!("channels.enabled: unknown channel '{name}'"));
1856            }
1857        }
1858        // Warn if 'web' is listed in channels — it should be in surfaces
1859        if self.channels.enabled.iter().any(|c| c == "web") {
1860            warnings.push(
1861                "channels.enabled: 'web' should be listed under [surfaces], not [channels]".into(),
1862            );
1863        }
1864        if self.channels.enabled.iter().any(|c| c == "telegram")
1865            && std::env::var(&self.channels.telegram.bot_token_env).is_err()
1866        {
1867            warnings.push(format!(
1868                "channels.telegram: {} env var not set — telegram channel will fail",
1869                self.channels.telegram.bot_token_env
1870            ));
1871        }
1872
1873        (errors, warnings)
1874    }
1875}
1876
1877/// Expand `~/` in paths to the user's home directory.
1878///
1879/// Shared utility for path expansion across the binary and kernel.
1880pub fn expand_home(path: &str) -> std::path::PathBuf {
1881    if let Some(rest) = path.strip_prefix("~/") {
1882        if let Ok(home) = std::env::var("HOME") {
1883            return std::path::PathBuf::from(format!("{home}/{rest}"));
1884        }
1885    }
1886    std::path::PathBuf::from(path)
1887}
1888
1889#[cfg(test)]
1890mod tests {
1891    use super::*;
1892
1893    #[test]
1894    fn test_default_config_validates() {
1895        let config = OxiosConfig::default();
1896        let (errors, _warnings) = config.validate();
1897        assert!(
1898            errors.is_empty(),
1899            "Default config should have no errors: {:?}",
1900            errors
1901        );
1902    }
1903
1904    #[test]
1905    fn test_exec_config_default_allowed_commands() {
1906        let config = ExecConfig::default();
1907        // Default is Enforced mode — empty list means NOTHING allowed.
1908        assert!(config.allowed_commands.is_empty());
1909        assert_eq!(config.allowlist_mode, AllowlistMode::Enforced);
1910        assert!(!config.is_binary_allowed("anything"));
1911        assert!(!config.is_binary_allowed("bash"));
1912    }
1913
1914    #[test]
1915    fn test_exec_config_permissive_mode() {
1916        let config = ExecConfig {
1917            allowlist_mode: AllowlistMode::Permissive,
1918            ..Default::default()
1919        };
1920        // Permissive + empty list = all allowed
1921        assert!(config.is_binary_allowed("anything"));
1922        assert!(config.is_binary_allowed("bash"));
1923    }
1924
1925    #[test]
1926    fn test_is_binary_allowed_with_allowlist() {
1927        let config = ExecConfig {
1928            allowed_commands: vec!["git".into(), "echo".into()],
1929            ..Default::default()
1930        };
1931        assert!(config.is_binary_allowed("git"));
1932        assert!(config.is_binary_allowed("echo"));
1933        assert!(!config.is_binary_allowed("bash"));
1934        assert!(!config.is_binary_allowed("rm"));
1935        assert!(!config.is_binary_allowed("sudo"));
1936    }
1937
1938    #[test]
1939    fn test_expand_home() {
1940        // With HOME set.
1941        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp/testhome".into());
1942        let expanded = expand_home("~/projects/test");
1943        assert_eq!(
1944            expanded.to_str().unwrap(),
1945            format!("{}/projects/test", home)
1946        );
1947
1948        // Non-tilde path should pass through unchanged.
1949        let abs = expand_home("/absolute/path");
1950        assert_eq!(abs, std::path::PathBuf::from("/absolute/path"));
1951
1952        // Just ~ without slash should not expand.
1953        let bare = expand_home("~something");
1954        assert_eq!(bare, std::path::PathBuf::from("~something"));
1955    }
1956
1957    #[test]
1958    fn test_invalid_cron_expression() {
1959        let mut config = OxiosConfig::default();
1960        config.cron.enabled = true;
1961        config.cron.jobs.insert(
1962            "bad-job".to_string(),
1963            InlineCronJob {
1964                schedule: "not a valid cron".to_string(),
1965                goal: "Test goal".to_string(),
1966                constraints: vec![],
1967                acceptance_criteria: vec![],
1968                toolchain: "default".to_string(),
1969                priority: Priority::Normal,
1970                enabled: true,
1971            },
1972        );
1973
1974        let (errors, _warnings) = config.validate();
1975        assert!(
1976            !errors.is_empty(),
1977            "Expected validation error for invalid cron"
1978        );
1979        let has_cron_error = errors.iter().any(|e| e.contains("invalid cron expression"));
1980        assert!(
1981            has_cron_error,
1982            "Expected 'invalid cron expression' error, got: {:?}",
1983            errors
1984        );
1985    }
1986
1987    #[test]
1988    fn test_config_serialization_roundtrip() {
1989        let config = OxiosConfig::default();
1990
1991        // Serialize to TOML string.
1992        let toml_str = toml::to_string(&config).expect("serialization should succeed");
1993
1994        // Deserialize back.
1995        let deserialized: OxiosConfig =
1996            toml::from_str(&toml_str).expect("deserialization should succeed");
1997
1998        // Key fields should match.
1999        assert_eq!(config.kernel.max_agents, deserialized.kernel.max_agents);
2000        assert_eq!(config.kernel.workspace, deserialized.kernel.workspace);
2001        assert_eq!(config.gateway.host, deserialized.gateway.host);
2002        assert_eq!(config.gateway.port, deserialized.gateway.port);
2003        assert_eq!(
2004            config.exec.default_timeout_secs,
2005            deserialized.exec.default_timeout_secs
2006        );
2007        assert_eq!(
2008            config.exec.max_timeout_secs,
2009            deserialized.exec.max_timeout_secs
2010        );
2011    }
2012
2013    #[test]
2014    fn test_exec_timeout_validation() {
2015        let mut config = OxiosConfig::default();
2016        // default_timeout > max_timeout should be an error.
2017        config.exec.default_timeout_secs = 999;
2018        config.exec.max_timeout_secs = 100;
2019        let (errors, _warnings) = config.validate();
2020        let has_error = errors.iter().any(|e| e.contains("must not exceed"));
2021        assert!(
2022            has_error,
2023            "Expected timeout ordering error, got: {:?}",
2024            errors
2025        );
2026    }
2027
2028    #[test]
2029    fn test_zero_max_agents_error() {
2030        let mut config = OxiosConfig::default();
2031        config.kernel.max_agents = 0;
2032        let (errors, _warnings) = config.validate();
2033        assert!(errors.iter().any(|e| e.contains("max_agents must be > 0")));
2034    }
2035
2036    /// Rust Default와 share/default-config.toml 간 핵심 기본값 일치 확인.
2037    /// TOML 템플릿은 "프로덕션 준비" 기본값을 가지며,
2038    /// Rust Default는 "안전한 최소" 기본값을 가질 수 있음.
2039    /// 핵심 스칼라 값(포트, 호스트, max_agents 등)은 반드시 일치해야 함.
2040    #[test]
2041    fn test_default_config_matches_toml() {
2042        let from_rust = OxiosConfig::default();
2043
2044        let toml_str = include_str!("../../../share/default-config.toml");
2045        let from_toml: OxiosConfig =
2046            toml::from_str(toml_str).expect("share/default-config.toml이 유효하지 않습니다");
2047
2048        // 핵심 스칼라 필드 — Rust와 TOML이 반드시 일치해야 함
2049        assert_eq!(
2050            from_rust.kernel.max_agents, from_toml.kernel.max_agents,
2051            "kernel.max_agents 불일치: Rust={}, TOML={}",
2052            from_rust.kernel.max_agents, from_toml.kernel.max_agents
2053        );
2054        assert_eq!(
2055            from_rust.gateway.host, from_toml.gateway.host,
2056            "gateway.host 불일치: Rust={}, TOML={}",
2057            from_rust.gateway.host, from_toml.gateway.host
2058        );
2059        assert_eq!(
2060            from_rust.gateway.port, from_toml.gateway.port,
2061            "gateway.port 불일치: Rust={}, TOML={}",
2062            from_rust.gateway.port, from_toml.gateway.port
2063        );
2064        assert_eq!(
2065            from_rust.kernel.event_bus_capacity, from_toml.kernel.event_bus_capacity,
2066            "kernel.event_bus_capacity 불일치"
2067        );
2068        assert_eq!(
2069            from_rust.scheduler.max_concurrent, from_toml.scheduler.max_concurrent,
2070            "scheduler.max_concurrent 불일치"
2071        );
2072        assert_eq!(
2073            from_rust.memory.consolidation.preset, from_toml.memory.consolidation.preset,
2074            "memory.consolidation.preset 불일치"
2075        );
2076
2077        // TOML 템플릿이 파싱 가능한지 확인
2078        let (_, warnings) = from_toml.validate();
2079        for w in &warnings {
2080            eprintln!("default-config.toml 경고: {}", w);
2081        }
2082    }
2083
2084    /// `gateway.expose_api_docs` is gated to loopback binds for safety.
2085    /// Verifies all four cases: opt-out, opt-in + public, opt-in + loopback.
2086    #[test]
2087    fn test_gateway_should_expose_api_docs() {
2088        // Default: opt-out — never expose.
2089        let cfg = GatewayConfig::default();
2090        assert!(!cfg.should_expose_api_docs());
2091
2092        // Opt-in + public bind (0.0.0.0) — still NOT exposed.
2093        let cfg = GatewayConfig {
2094            host: "0.0.0.0".into(),
2095            port: 4200,
2096            expose_api_docs: true,
2097        };
2098        assert!(
2099            !cfg.should_expose_api_docs(),
2100            "public bind must not expose api docs even when opt-in is true"
2101        );
2102
2103        // Opt-in + loopback (127.0.0.1) — exposed.
2104        let cfg = GatewayConfig {
2105            host: "127.0.0.1".into(),
2106            port: 4200,
2107            expose_api_docs: true,
2108        };
2109        assert!(cfg.should_expose_api_docs());
2110
2111        // Opt-in + ::1 — exposed.
2112        let cfg = GatewayConfig {
2113            host: "::1".into(),
2114            port: 4200,
2115            expose_api_docs: true,
2116        };
2117        assert!(cfg.should_expose_api_docs());
2118
2119        // Opt-in + "localhost" — exposed.
2120        let cfg = GatewayConfig {
2121            host: "localhost".into(),
2122            port: 4200,
2123            expose_api_docs: true,
2124        };
2125        assert!(cfg.should_expose_api_docs());
2126
2127        // Opt-out (explicit false) + loopback — NOT exposed.
2128        let cfg = GatewayConfig {
2129            host: "127.0.0.1".into(),
2130            port: 4200,
2131            expose_api_docs: false,
2132        };
2133        assert!(!cfg.should_expose_api_docs());
2134    }
2135}