1mod migrate;
2mod schema;
3pub mod watcher;
4
5pub use migrate::check_openclaw_detected;
6pub use schema::*;
7pub use watcher::{ConfigWatcher, spawn_sighup_handler};
8
9use anyhow::Result;
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::PathBuf;
13
14use crate::env::LOCALGPT_WORKSPACE;
15use crate::paths::Paths;
16use crate::paths::{DEFAULT_DATA_DIR_STR, DEFAULT_STATE_DIR_STR};
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19pub struct Config {
20 #[serde(skip)]
22 pub paths: Paths,
23
24 #[serde(default)]
25 pub agent: AgentConfig,
26
27 #[serde(default)]
28 pub providers: ProvidersConfig,
29
30 #[serde(default)]
31 pub heartbeat: HeartbeatConfig,
32
33 #[serde(default)]
34 pub memory: MemoryConfig,
35
36 #[serde(default)]
37 pub server: ServerConfig,
38
39 #[serde(default)]
40 pub logging: LoggingConfig,
41
42 #[serde(default)]
43 pub tools: ToolsConfig,
44
45 #[serde(default)]
46 pub security: SecurityConfig,
47
48 #[serde(default)]
49 pub sandbox: SandboxConfig,
50
51 #[serde(default)]
52 pub telegram: Option<TelegramConfig>,
53
54 #[serde(default)]
55 pub cron: CronConfig,
56
57 #[serde(default)]
58 pub hooks: HooksConfig,
59
60 #[serde(default)]
61 pub mcp: McpConfig,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct AgentConfig {
66 #[serde(default = "default_model")]
67 pub default_model: String,
68
69 #[serde(default = "default_context_window")]
70 pub context_window: usize,
71
72 #[serde(default = "default_reserve_tokens")]
73 pub reserve_tokens: usize,
74
75 #[serde(default = "default_max_tokens")]
77 pub max_tokens: usize,
78
79 #[serde(default)]
84 pub max_spawn_depth: Option<u8>,
85
86 #[serde(default)]
88 pub subagent_model: Option<String>,
89
90 #[serde(default)]
94 pub fallback_models: Vec<String>,
95
96 #[serde(default = "default_max_tool_repeats")]
99 pub max_tool_repeats: usize,
100
101 #[serde(default = "default_session_max_age")]
104 pub session_max_age: u64,
105
106 #[serde(default = "default_session_max_count")]
109 pub session_max_count: usize,
110}
111
112fn default_max_tool_repeats() -> usize {
113 3
114}
115
116fn default_session_max_age() -> u64 {
117 30 * 24 * 60 * 60 }
119
120fn default_session_max_count() -> usize {
121 500
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct ToolsConfig {
126 #[serde(default = "default_bash_timeout")]
128 pub bash_timeout_ms: u64,
129
130 #[serde(default = "default_web_fetch_max_bytes")]
132 pub web_fetch_max_bytes: usize,
133
134 #[serde(default)]
137 pub require_approval: Vec<String>,
138
139 #[serde(default = "default_tool_output_max_chars")]
141 pub tool_output_max_chars: usize,
142
143 #[serde(default = "default_true")]
145 pub log_injection_warnings: bool,
146
147 #[serde(default = "default_true")]
149 pub use_content_delimiters: bool,
150
151 #[serde(default)]
153 pub web_search: Option<WebSearchConfig>,
154
155 #[serde(default)]
158 pub filters: std::collections::HashMap<String, crate::agent::tool_filters::ToolFilter>,
159}
160
161#[derive(Debug, Clone, Default, Serialize, Deserialize)]
162#[serde(rename_all = "lowercase")]
163pub enum SearchProviderType {
164 Searxng,
165 Brave,
166 Tavily,
167 Perplexity,
168 #[default]
169 None,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct WebSearchConfig {
174 #[serde(default)]
175 pub provider: SearchProviderType,
176
177 #[serde(default = "default_true")]
178 pub cache_enabled: bool,
179
180 #[serde(default = "default_cache_ttl")]
182 pub cache_ttl: u64,
183
184 #[serde(default = "default_max_results")]
186 pub max_results: u8,
187
188 #[serde(default = "default_true")]
190 pub prefer_native: bool,
191
192 #[serde(default)]
193 pub searxng: Option<SearxngConfig>,
194
195 #[serde(default)]
196 pub brave: Option<BraveConfig>,
197
198 #[serde(default)]
199 pub tavily: Option<TavilyConfig>,
200
201 #[serde(default)]
202 pub perplexity: Option<PerplexityConfig>,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct SearxngConfig {
207 pub base_url: String,
208
209 #[serde(default)]
210 pub categories: String,
211
212 #[serde(default)]
213 pub language: String,
214
215 #[serde(default)]
216 pub time_range: String,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct BraveConfig {
221 pub api_key: String,
222
223 #[serde(default)]
224 pub country: String,
225
226 #[serde(default)]
227 pub freshness: String,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct TavilyConfig {
232 pub api_key: String,
233
234 #[serde(default = "default_basic")]
235 pub search_depth: String,
236
237 #[serde(default = "default_true")]
238 pub include_answer: bool,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct PerplexityConfig {
243 pub api_key: String,
244
245 #[serde(default = "default_sonar")]
246 pub model: String,
247}
248
249#[derive(Debug, Clone, Default, Serialize, Deserialize)]
250pub struct SecurityConfig {
251 #[serde(default)]
257 pub strict_policy: bool,
258
259 #[serde(default)]
266 pub disable_policy: bool,
267
268 #[serde(default)]
279 pub disable_suffix: bool,
280
281 #[serde(default)]
284 pub allowed_directories: Vec<String>,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct SandboxConfig {
289 #[serde(default = "default_true")]
291 pub enabled: bool,
292
293 #[serde(default = "default_sandbox_level")]
295 pub level: String,
296
297 #[serde(default = "default_sandbox_timeout")]
299 pub timeout_secs: u64,
300
301 #[serde(default = "default_sandbox_max_output")]
303 pub max_output_bytes: u64,
304
305 #[serde(default = "default_sandbox_max_file_size")]
307 pub max_file_size_bytes: u64,
308
309 #[serde(default = "default_sandbox_max_processes")]
311 pub max_processes: u32,
312
313 #[serde(default)]
315 pub allow_paths: AllowPathsConfig,
316
317 #[serde(default)]
319 pub network: SandboxNetworkConfig,
320}
321
322#[derive(Debug, Clone, Default, Serialize, Deserialize)]
323pub struct AllowPathsConfig {
324 #[serde(default)]
326 pub read: Vec<String>,
327
328 #[serde(default)]
330 pub write: Vec<String>,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct SandboxNetworkConfig {
335 #[serde(default = "default_sandbox_network_policy")]
337 pub policy: String,
338}
339
340impl Default for SandboxConfig {
341 fn default() -> Self {
342 Self {
343 enabled: default_true(),
344 level: default_sandbox_level(),
345 timeout_secs: default_sandbox_timeout(),
346 max_output_bytes: default_sandbox_max_output(),
347 max_file_size_bytes: default_sandbox_max_file_size(),
348 max_processes: default_sandbox_max_processes(),
349 allow_paths: AllowPathsConfig::default(),
350 network: SandboxNetworkConfig::default(),
351 }
352 }
353}
354
355impl Default for SandboxNetworkConfig {
356 fn default() -> Self {
357 Self {
358 policy: default_sandbox_network_policy(),
359 }
360 }
361}
362
363#[derive(Debug, Clone, Default, Serialize, Deserialize)]
364pub struct ProvidersConfig {
365 #[serde(default)]
366 pub openai: Option<OpenAIConfig>,
367
368 #[serde(default)]
369 pub xai: Option<XaiConfig>,
370
371 #[serde(default)]
372 pub anthropic: Option<AnthropicConfig>,
373
374 #[serde(default)]
375 pub ollama: Option<OllamaConfig>,
376
377 #[serde(default)]
378 pub claude_cli: Option<ClaudeCliConfig>,
379
380 #[serde(default)]
381 pub gemini_cli: Option<GeminiCliConfig>,
382
383 #[serde(default)]
384 pub codex_cli: Option<CodexCliConfig>,
385
386 #[serde(default)]
387 pub glm: Option<GlmConfig>,
388
389 #[serde(default)]
390 pub gemini: Option<GeminiConfig>,
391
392 #[serde(default)]
395 pub openai_compatible: Option<OpenAICompatibleConfig>,
396
397 #[serde(default)]
399 pub vertex: Option<VertexAiConfig>,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct OpenAICompatibleConfig {
405 pub base_url: String,
407
408 pub api_key: String,
410
411 #[serde(default)]
413 pub extra_headers: std::collections::HashMap<String, String>,
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct VertexAiConfig {
419 pub service_account_key: String,
421
422 pub project_id: String,
424
425 #[serde(default = "default_vertex_location")]
427 pub location: String,
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct OpenAIConfig {
432 pub api_key: String,
433
434 #[serde(default = "default_openai_base_url")]
435 pub base_url: String,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct XaiConfig {
440 pub api_key: String,
441
442 #[serde(default = "default_xai_base_url")]
443 pub base_url: String,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct AnthropicConfig {
448 pub api_key: String,
449
450 #[serde(default = "default_anthropic_base_url")]
451 pub base_url: String,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct OllamaConfig {
456 #[serde(default = "default_ollama_endpoint")]
457 pub endpoint: String,
458
459 #[serde(default = "default_ollama_model")]
460 pub model: String,
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct ClaudeCliConfig {
465 #[serde(default = "default_claude_cli_command")]
466 pub command: String,
467
468 #[serde(default = "default_claude_cli_model")]
469 pub model: String,
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct GeminiCliConfig {
474 #[serde(default = "default_gemini_cli_command")]
475 pub command: String,
476
477 #[serde(default = "default_gemini_cli_model")]
478 pub model: String,
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct CodexCliConfig {
483 #[serde(default = "default_codex_cli_command")]
484 pub command: String,
485
486 #[serde(default = "default_codex_cli_model")]
487 pub model: String,
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct GlmConfig {
492 pub api_key: String,
493
494 #[serde(default = "default_glm_base_url")]
495 pub base_url: String,
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
499pub struct GeminiConfig {
500 pub api_key: String,
501
502 #[serde(default = "default_gemini_base_url")]
503 pub base_url: String,
504}
505
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct HeartbeatConfig {
508 #[serde(default = "default_true")]
509 pub enabled: bool,
510
511 #[serde(default = "default_interval")]
512 pub interval: String,
513
514 #[serde(default = "default_overdue_delay")]
515 pub overdue_delay: String,
516
517 #[serde(default)]
521 pub timeout: Option<String>,
522
523 #[serde(default)]
524 pub active_hours: Option<ActiveHours>,
525
526 #[serde(default)]
527 pub timezone: Option<String>,
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct ActiveHours {
532 pub start: String,
533 pub end: String,
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct MemoryConfig {
538 #[serde(default = "default_workspace")]
539 pub workspace: String,
540
541 #[serde(default = "default_embedding_provider")]
543 pub embedding_provider: String,
544
545 #[serde(default = "default_embedding_model")]
546 pub embedding_model: String,
547
548 #[serde(default = "default_embedding_cache_dir")]
552 pub embedding_cache_dir: String,
553
554 #[serde(default = "default_chunk_size")]
555 pub chunk_size: usize,
556
557 #[serde(default = "default_chunk_overlap")]
558 pub chunk_overlap: usize,
559
560 #[serde(default = "default_index_paths")]
563 pub paths: Vec<MemoryIndexPath>,
564
565 #[serde(default = "default_session_max_messages")]
568 pub session_max_messages: usize,
569
570 #[serde(default)]
573 pub session_max_chars: usize,
574
575 #[serde(default)]
581 pub temporal_decay_lambda: f64,
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize)]
585pub struct MemoryIndexPath {
586 pub path: String,
587 #[serde(default = "default_pattern")]
588 pub pattern: String,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct ServerConfig {
593 #[serde(default = "default_true")]
594 pub enabled: bool,
595
596 #[serde(default = "default_port")]
597 pub port: u16,
598
599 #[serde(default = "default_bind")]
600 pub bind: String,
601
602 #[serde(default)]
607 pub auth_token: Option<String>,
608
609 #[serde(default)]
610 pub rate_limit: RateLimitConfig,
611
612 #[serde(default)]
614 pub cors_origins: Vec<String>,
615
616 #[serde(default = "default_max_request_body")]
620 pub max_request_body: usize,
621}
622
623fn default_max_request_body() -> usize {
624 10 * 1024 * 1024 }
626
627#[derive(Debug, Clone, Serialize, Deserialize)]
628pub struct RateLimitConfig {
629 #[serde(default = "default_true")]
630 pub enabled: bool,
631
632 #[serde(default = "default_requests_per_minute")]
634 pub requests_per_minute: u32,
635
636 #[serde(default = "default_burst")]
638 pub burst: u32,
639}
640
641#[derive(Debug, Clone, Serialize, Deserialize)]
642pub struct LoggingConfig {
643 #[serde(default = "default_log_level")]
644 pub level: String,
645
646 #[serde(default = "default_log_file")]
647 pub file: String,
648
649 #[serde(default)]
651 pub retention_days: u32,
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize)]
655pub struct TelegramConfig {
656 #[serde(default)]
657 pub enabled: bool,
658
659 pub api_token: String,
660}
661
662#[derive(Debug, Clone, Default, Serialize, Deserialize)]
663pub struct CronConfig {
664 #[serde(default)]
665 pub jobs: Vec<CronJob>,
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize)]
669pub struct CronJob {
670 pub name: String,
671
672 pub schedule: String,
674
675 pub prompt: String,
677
678 #[serde(default)]
680 pub channel: Option<String>,
681
682 #[serde(default = "default_true")]
683 pub enabled: bool,
684
685 #[serde(default = "default_cron_timeout")]
687 pub timeout: String,
688}
689
690#[derive(Debug, Clone, Default, Serialize, Deserialize)]
691pub struct HooksConfig {
692 #[serde(default)]
693 pub hooks: Vec<HookConfig>,
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize)]
697pub struct HookConfig {
698 pub name: String,
700
701 pub event: String,
703
704 pub command: String,
706
707 #[serde(default)]
709 pub filter: Option<String>,
710
711 #[serde(default = "default_true")]
713 pub enabled: bool,
714}
715
716#[derive(Debug, Clone, Default, Serialize, Deserialize)]
717pub struct McpConfig {
718 #[serde(default)]
719 pub servers: Vec<McpServerConfig>,
720}
721
722#[derive(Debug, Clone, Serialize, Deserialize)]
723pub struct McpServerConfig {
724 pub name: String,
726
727 #[serde(default = "default_mcp_transport")]
729 pub transport: String,
730
731 pub command: Option<String>,
733
734 #[serde(default)]
736 pub args: Vec<String>,
737
738 #[serde(default)]
740 pub env: std::collections::HashMap<String, String>,
741
742 pub url: Option<String>,
744}
745
746fn default_mcp_transport() -> String {
747 "stdio".to_string()
748}
749
750fn default_model() -> String {
752 "claude-cli/opus".to_string()
754}
755fn default_context_window() -> usize {
756 128000
757}
758fn default_reserve_tokens() -> usize {
759 8000
760}
761fn default_max_tokens() -> usize {
762 4096
763}
764fn default_bash_timeout() -> u64 {
765 30000 }
767fn default_web_fetch_max_bytes() -> usize {
768 10000
769}
770fn default_tool_output_max_chars() -> usize {
771 50000 }
773fn default_openai_base_url() -> String {
774 "https://api.openai.com/v1".to_string()
775}
776fn default_xai_base_url() -> String {
777 "https://api.x.ai/v1".to_string()
778}
779fn default_anthropic_base_url() -> String {
780 "https://api.anthropic.com".to_string()
781}
782fn default_ollama_endpoint() -> String {
783 "http://localhost:11434".to_string()
784}
785fn default_ollama_model() -> String {
786 "llama3".to_string()
787}
788fn default_claude_cli_command() -> String {
789 "claude".to_string()
790}
791fn default_claude_cli_model() -> String {
792 "opus".to_string()
793}
794fn default_gemini_cli_command() -> String {
795 "gemini".to_string()
796}
797fn default_gemini_cli_model() -> String {
798 "gemini-3.1-pro-preview".to_string()
799}
800fn default_codex_cli_command() -> String {
801 "codex".to_string()
802}
803fn default_codex_cli_model() -> String {
804 "o4-mini".to_string()
805}
806fn default_glm_base_url() -> String {
807 "https://api.z.ai/api/coding/paas/v4".to_string()
808}
809fn default_gemini_base_url() -> String {
810 "https://generativelanguage.googleapis.com".to_string()
811}
812fn default_vertex_location() -> String {
813 "us-central1".to_string()
814}
815fn default_true() -> bool {
816 true
817}
818fn default_interval() -> String {
819 "30m".to_string()
820}
821
822fn default_overdue_delay() -> String {
823 "1m".to_string()
824}
825fn default_workspace() -> String {
826 format!("{}/workspace", DEFAULT_DATA_DIR_STR)
827}
828fn default_embedding_provider() -> String {
829 "local".to_string() }
831fn default_embedding_model() -> String {
832 "all-MiniLM-L6-v2".to_string() }
834fn default_embedding_cache_dir() -> String {
835 crate::paths::DEFAULT_CACHE_DIR_STR.to_string() + "/embeddings"
836}
837fn default_chunk_size() -> usize {
838 400
839}
840fn default_chunk_overlap() -> usize {
841 80
842}
843fn default_index_paths() -> Vec<MemoryIndexPath> {
844 vec![MemoryIndexPath {
845 path: "knowledge".to_string(),
846 pattern: "**/*.md".to_string(),
847 }]
848}
849fn default_pattern() -> String {
850 "**/*.md".to_string()
851}
852fn default_session_max_messages() -> usize {
853 15 }
855fn default_port() -> u16 {
856 31327
857}
858fn default_cron_timeout() -> String {
859 "10m".to_string()
860}
861fn default_requests_per_minute() -> u32 {
862 60
863}
864fn default_burst() -> u32 {
865 10
866}
867fn default_bind() -> String {
868 "127.0.0.1".to_string()
869}
870fn default_log_level() -> String {
871 "info".to_string()
872}
873fn default_log_file() -> String {
874 format!("{}/logs/agent.log", DEFAULT_STATE_DIR_STR)
875}
876fn default_sandbox_level() -> String {
877 "auto".to_string()
878}
879fn default_sandbox_timeout() -> u64 {
880 120
881}
882fn default_sandbox_max_output() -> u64 {
883 1_048_576 }
885fn default_sandbox_max_file_size() -> u64 {
886 52_428_800 }
888fn default_sandbox_max_processes() -> u32 {
889 64
890}
891fn default_sandbox_network_policy() -> String {
892 "deny".to_string()
893}
894fn default_cache_ttl() -> u64 {
895 900 }
897fn default_max_results() -> u8 {
898 5
899}
900fn default_basic() -> String {
901 "basic".to_string()
902}
903fn default_sonar() -> String {
904 "sonar".to_string()
905}
906
907impl Default for AgentConfig {
908 fn default() -> Self {
909 Self {
910 default_model: default_model(),
911 context_window: default_context_window(),
912 reserve_tokens: default_reserve_tokens(),
913 max_tokens: default_max_tokens(),
914 max_spawn_depth: Some(1), subagent_model: None, fallback_models: Vec::new(), max_tool_repeats: default_max_tool_repeats(), session_max_age: default_session_max_age(), session_max_count: default_session_max_count(), }
921 }
922}
923
924impl Default for ToolsConfig {
925 fn default() -> Self {
926 Self {
927 bash_timeout_ms: default_bash_timeout(),
928 web_fetch_max_bytes: default_web_fetch_max_bytes(),
929 require_approval: Vec::new(),
930 tool_output_max_chars: default_tool_output_max_chars(),
931 log_injection_warnings: default_true(),
932 use_content_delimiters: default_true(),
933 web_search: None,
934 filters: std::collections::HashMap::new(),
935 }
936 }
937}
938
939impl Default for HeartbeatConfig {
940 fn default() -> Self {
941 Self {
942 enabled: default_true(),
943 interval: default_interval(),
944 overdue_delay: default_overdue_delay(),
945 timeout: None,
946 active_hours: None,
947 timezone: None,
948 }
949 }
950}
951
952impl Default for MemoryConfig {
953 fn default() -> Self {
954 Self {
955 workspace: default_workspace(),
956 embedding_provider: default_embedding_provider(),
957 embedding_model: default_embedding_model(),
958 embedding_cache_dir: default_embedding_cache_dir(),
959 chunk_size: default_chunk_size(),
960 chunk_overlap: default_chunk_overlap(),
961 paths: default_index_paths(),
962 session_max_messages: default_session_max_messages(),
963 session_max_chars: 0, temporal_decay_lambda: 0.0, }
966 }
967}
968
969impl Default for ServerConfig {
970 fn default() -> Self {
971 Self {
972 enabled: default_true(),
973 port: default_port(),
974 bind: default_bind(),
975 auth_token: None,
976 rate_limit: RateLimitConfig::default(),
977 cors_origins: Vec::new(),
978 max_request_body: default_max_request_body(),
979 }
980 }
981}
982
983impl Default for RateLimitConfig {
984 fn default() -> Self {
985 Self {
986 enabled: default_true(),
987 requests_per_minute: default_requests_per_minute(),
988 burst: default_burst(),
989 }
990 }
991}
992
993impl Default for LoggingConfig {
994 fn default() -> Self {
995 Self {
996 level: default_log_level(),
997 file: default_log_file(),
998 retention_days: 0, }
1000 }
1001}
1002
1003impl Config {
1004 pub fn load() -> Result<Self> {
1005 let paths = Paths::resolve()?;
1006 paths.ensure_dirs()?;
1007 let path = paths.config_file();
1008
1009 if !path.exists() {
1010 let config = Config {
1012 paths,
1013 ..Config::default()
1014 };
1015 config.save_with_template()?;
1016 return Ok(config);
1017 }
1018
1019 let content = fs::read_to_string(&path)?;
1020 let mut config: Config = toml::from_str(&content)?;
1021 config.paths = paths;
1022
1023 config.expand_env_vars();
1025
1026 if config.memory.workspace != default_workspace()
1028 && std::env::var(LOCALGPT_WORKSPACE).is_err()
1029 {
1030 let expanded = shellexpand::tilde(&config.memory.workspace);
1031 let ws_path = PathBuf::from(expanded.to_string());
1032 if ws_path.is_absolute() {
1033 config.paths.workspace = ws_path;
1034 }
1035 }
1036
1037 Ok(config)
1038 }
1039
1040 pub fn load_from_dir(data_dir: &str) -> Result<Self> {
1044 let paths = Paths::from_root(data_dir);
1045 paths.ensure_dirs()?;
1046 let path = paths.config_file();
1047
1048 if !path.exists() {
1049 let config = Config {
1050 paths,
1051 ..Config::default()
1052 };
1053 config.save()?;
1054 return Ok(config);
1055 }
1056
1057 let content = fs::read_to_string(&path)?;
1058 let mut config: Config = toml::from_str(&content)?;
1059 config.paths = paths;
1060 config.expand_env_vars();
1061 Ok(config)
1062 }
1063
1064 pub fn save(&self) -> Result<()> {
1065 let path = self.paths.config_file();
1066
1067 if let Some(parent) = path.parent() {
1069 fs::create_dir_all(parent)?;
1070 }
1071
1072 let content = toml::to_string_pretty(self)?;
1073 fs::write(&path, content)?;
1074
1075 Ok(())
1076 }
1077
1078 pub fn save_with_template(&self) -> Result<()> {
1080 let path = self.paths.config_file();
1081
1082 if let Some(parent) = path.parent() {
1084 fs::create_dir_all(parent)?;
1085 }
1086
1087 fs::write(&path, DEFAULT_CONFIG_TEMPLATE)?;
1088 eprintln!("Created default config at {}", path.display());
1089
1090 Ok(())
1091 }
1092
1093 pub fn config_path() -> Result<PathBuf> {
1094 let paths = Paths::resolve()?;
1095 Ok(paths.config_file())
1096 }
1097
1098 fn expand_env_vars(&mut self) {
1099 if let Some(ref mut openai) = self.providers.openai {
1100 openai.api_key = expand_env(&openai.api_key);
1101 }
1102 if let Some(ref mut xai) = self.providers.xai {
1103 xai.api_key = expand_env(&xai.api_key);
1104 }
1105 if let Some(ref mut anthropic) = self.providers.anthropic {
1106 anthropic.api_key = expand_env(&anthropic.api_key);
1107 }
1108 if let Some(ref mut telegram) = self.telegram {
1109 telegram.api_token = expand_env(&telegram.api_token);
1110 }
1111 if let Some(ref mut ws) = self.tools.web_search
1112 && let Some(ref mut brave) = ws.brave
1113 {
1114 brave.api_key = expand_env(&brave.api_key);
1115 }
1116 if let Some(ref mut ws) = self.tools.web_search
1117 && let Some(ref mut tavily) = ws.tavily
1118 {
1119 tavily.api_key = expand_env(&tavily.api_key);
1120 }
1121 if let Some(ref mut ws) = self.tools.web_search
1122 && let Some(ref mut perplexity) = ws.perplexity
1123 {
1124 perplexity.api_key = expand_env(&perplexity.api_key);
1125 }
1126 if let Some(ref mut gemini) = self.providers.gemini {
1127 gemini.api_key = expand_env(&gemini.api_key);
1128 }
1129 if let Some(ref mut openai_compat) = self.providers.openai_compatible {
1130 openai_compat.api_key = expand_env(&openai_compat.api_key);
1131 openai_compat.base_url = expand_env(&openai_compat.base_url);
1132 }
1133 if let Some(ref mut vertex) = self.providers.vertex {
1134 vertex.service_account_key = expand_env(&vertex.service_account_key);
1135 vertex.project_id = expand_env(&vertex.project_id);
1136 }
1137 if let Some(ref mut auth_token) = self.server.auth_token {
1138 *auth_token = expand_env(auth_token);
1139 }
1140 }
1141
1142 pub fn get_value(&self, key: &str) -> Result<String> {
1143 let parts: Vec<&str> = key.split('.').collect();
1144
1145 match parts.as_slice() {
1146 ["agent", "default_model"] => Ok(self.agent.default_model.clone()),
1147 ["agent", "context_window"] => Ok(self.agent.context_window.to_string()),
1148 ["agent", "reserve_tokens"] => Ok(self.agent.reserve_tokens.to_string()),
1149 ["heartbeat", "enabled"] => Ok(self.heartbeat.enabled.to_string()),
1150 ["heartbeat", "interval"] => Ok(self.heartbeat.interval.clone()),
1151 ["server", "enabled"] => Ok(self.server.enabled.to_string()),
1152 ["server", "port"] => Ok(self.server.port.to_string()),
1153 ["server", "bind"] => Ok(self.server.bind.clone()),
1154 ["memory", "workspace"] => Ok(self.memory.workspace.clone()),
1155 ["logging", "level"] => Ok(self.logging.level.clone()),
1156 _ => anyhow::bail!("Unknown config key: {}", key),
1157 }
1158 }
1159
1160 pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
1161 let parts: Vec<&str> = key.split('.').collect();
1162
1163 match parts.as_slice() {
1164 ["agent", "default_model"] => self.agent.default_model = value.to_string(),
1165 ["agent", "context_window"] => self.agent.context_window = value.parse()?,
1166 ["agent", "reserve_tokens"] => self.agent.reserve_tokens = value.parse()?,
1167 ["heartbeat", "enabled"] => self.heartbeat.enabled = value.parse()?,
1168 ["heartbeat", "interval"] => self.heartbeat.interval = value.to_string(),
1169 ["server", "enabled"] => self.server.enabled = value.parse()?,
1170 ["server", "port"] => self.server.port = value.parse()?,
1171 ["server", "bind"] => self.server.bind = value.to_string(),
1172 ["memory", "workspace"] => self.memory.workspace = value.to_string(),
1173 ["logging", "level"] => self.logging.level = value.to_string(),
1174 _ => anyhow::bail!("Unknown config key: {}", key),
1175 }
1176
1177 Ok(())
1178 }
1179
1180 pub fn workspace_path(&self) -> PathBuf {
1188 self.paths.workspace.clone()
1189 }
1190}
1191
1192fn expand_env(s: &str) -> String {
1193 if let Some(var_name) = s.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
1194 std::env::var(var_name).unwrap_or_else(|_| s.to_string())
1195 } else if let Some(var_name) = s.strip_prefix('$') {
1196 std::env::var(var_name).unwrap_or_else(|_| s.to_string())
1197 } else {
1198 s.to_string()
1199 }
1200}
1201
1202const DEFAULT_CONFIG_TEMPLATE: &str = r#"# LocalGPT Configuration
1204# Auto-created on first run. Edit as needed.
1205
1206[agent]
1207# Default model: claude-cli/opus, anthropic/claude-sonnet-4-5, openai/gpt-4o, xai/grok-3-mini, etc.
1208default_model = "claude-cli/opus"
1209context_window = 128000
1210reserve_tokens = 8000
1211
1212# Spawn agent (subagent) configuration
1213# max_spawn_depth = 1 # 0 = disabled, 1 = single level (default)
1214# subagent_model = "claude-cli/sonnet" # Model for subagents (default: same as default_model)
1215
1216# Failover configuration (optional)
1217# Automatically try fallback models if primary fails with retryable errors
1218# (rate limits, server errors, timeouts). Providers tried in order.
1219# fallback_models = ["openai/gpt-4o", "ollama/llama3"]
1220
1221# Loop detection (optional)
1222# Maximum times the same tool can be called with identical arguments
1223# before detection triggers. Default: 3. Set to 0 to disable.
1224# max_tool_repeats = 3
1225
1226# Anthropic API (for anthropic/* models)
1227# [providers.anthropic]
1228# api_key = "${ANTHROPIC_API_KEY}"
1229
1230# OpenAI API (for openai/* models)
1231# [providers.openai]
1232# api_key = "${OPENAI_API_KEY}"
1233
1234# xAI API (for xai/* models)
1235# [providers.xai]
1236# api_key = "${XAI_API_KEY}"
1237# base_url = "https://api.x.ai/v1"
1238
1239# OpenAI-Compatible provider (OpenRouter, DeepSeek, Groq, vLLM, LiteLLM, etc.)
1240# [providers.openai_compatible]
1241# base_url = "https://openrouter.ai/api/v1"
1242# api_key = "${OPENROUTER_API_KEY}"
1243# # Optional extra headers (e.g., OpenRouter attribution)
1244# extra_headers = { "HTTP-Referer" = "https://localgpt.app", "X-Title" = "LocalGPT" }
1245# # Use with: localgpt chat --model openai-compat/deepseek-chat
1246
1247# Claude CLI (for claude-cli/* models, requires claude CLI installed)
1248[providers.claude_cli]
1249command = "claude"
1250
1251[heartbeat]
1252enabled = true
1253interval = "30m"
1254
1255# Maximum wall-clock time for a single heartbeat run (optional).
1256# If the heartbeat LLM turn exceeds this deadline it is cancelled and
1257# a TimedOut event is recorded so the next interval can run on schedule.
1258# Defaults to half the interval (e.g., "15m" when interval = "30m").
1259# timeout = "15m"
1260
1261# Only run during these hours (optional)
1262# [heartbeat.active_hours]
1263# start = "09:00"
1264# end = "22:00"
1265
1266[memory]
1267# Workspace directory for memory files (MEMORY.md, HEARTBEAT.md, etc.)
1268# Default: XDG data dir (~/.local/share/localgpt/workspace)
1269# Override with environment variables:
1270# LOCALGPT_WORKSPACE=/path/to/workspace - absolute path override
1271# LOCALGPT_PROFILE=work - uses data_dir/workspace-work
1272# workspace = "~/.local/share/localgpt/workspace"
1273
1274# Session memory settings (for /new command)
1275# session_max_messages = 15 # Max messages to save (0 = unlimited)
1276# session_max_chars = 0 # Max chars per message (0 = unlimited, preserves full content)
1277
1278[server]
1279enabled = true
1280port = 31327
1281bind = "127.0.0.1"
1282# Optional bearer token for API authentication
1283# auth_token = "${LOCALGPT_AUTH_TOKEN}"
1284
1285[logging]
1286level = "info"
1287
1288# Shell sandbox (kernel-enforced isolation for LLM-generated commands)
1289# [sandbox]
1290# enabled = true # default: true
1291# level = "auto" # auto | full | standard | minimal | none
1292# timeout_secs = 120 # default: 120
1293# max_output_bytes = 1048576 # default: 1MB
1294#
1295# [sandbox.allow_paths]
1296# read = ["/data/datasets"] # additional read-only paths
1297# write = ["/tmp/builds"] # additional writable paths
1298#
1299# [sandbox.network]
1300# policy = "deny" # deny | proxy
1301
1302# Web search (optional)
1303# [tools.web_search]
1304# provider = "searxng" # searxng | brave | tavily | perplexity | none
1305# cache_enabled = true
1306# cache_ttl = 900 # seconds (default: 15 min)
1307# max_results = 5 # 1-10
1308# prefer_native = true # prefer native provider search when available
1309#
1310# [tools.web_search.searxng]
1311# base_url = "http://localhost:8080"
1312# categories = "general"
1313# language = "en"
1314#
1315# [tools.web_search.brave]
1316# api_key = "${BRAVE_API_KEY}"
1317#
1318# [tools.web_search.tavily]
1319# api_key = "${TAVILY_API_KEY}"
1320# search_depth = "basic" # basic | advanced
1321# include_answer = true
1322#
1323# [tools.web_search.perplexity]
1324# api_key = "${PERPLEXITY_API_KEY}"
1325# model = "sonar"
1326
1327# Telegram bot (optional)
1328# [telegram]
1329# enabled = true
1330# api_token = "${TELEGRAM_BOT_TOKEN}"
1331"#;