1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use tracing;
5
6use super::defaults::{default_config_version, default_tool_idle_timeout};
7use super::healing::HealingConfig;
8use super::mcp::McpServerEntry;
9use super::migration::{migrate_to_latest, save_config};
10use super::permissions::ToolPermission;
11use super::prompt::DEFAULT_SYSTEM_PROMPT;
12use super::provider::LlmProvider;
13use super::routing::{CloudConfig, ModelRouting};
14use super::target::TargetConfig;
15use super::tui::TuiConfig;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(default)]
20pub struct PawanConfig {
21 #[serde(default = "default_config_version")]
23 pub config_version: u32,
24
25 pub provider: LlmProvider,
27
28 pub model: String,
30
31 pub base_url: Option<String>,
34
35 pub dry_run: bool,
37
38 pub auto_backup: bool,
40
41 pub require_git_clean: bool,
43
44 pub bash_timeout_secs: u64,
46
47 #[serde(default = "default_tool_idle_timeout")]
50 pub tool_call_idle_timeout_secs: u64,
51
52 pub max_file_size_kb: usize,
54
55 pub max_tool_iterations: usize,
57 pub max_context_tokens: usize,
59
60 pub system_prompt: Option<String>,
62
63 pub temperature: f32,
65
66 pub top_p: f32,
68
69 pub max_tokens: usize,
71
72 pub thinking_budget: usize,
76
77 pub max_retries: usize,
79
80 pub fallback_models: Vec<String>,
82 pub max_result_chars: usize,
84
85 pub reasoning_mode: bool,
87
88 pub healing: HealingConfig,
90
91 pub targets: HashMap<String, TargetConfig>,
93
94 pub tui: TuiConfig,
96
97 #[serde(default)]
99 pub mcp: HashMap<String, McpServerEntry>,
100
101 #[serde(default)]
103 pub permissions: HashMap<String, ToolPermission>,
104
105 pub cloud: Option<CloudConfig>,
108
109 #[serde(default)]
112 pub models: ModelRouting,
113
114 #[serde(default)]
116 pub eruka: crate::eruka_bridge::ErukaConfig,
117
118 #[serde(default)]
124 pub use_ares_backend: bool,
125 #[serde(default)]
130 pub use_coordinator: bool,
131
132 #[serde(default)]
145 pub skills_repo: Option<PathBuf>,
146
147 #[serde(default)]
154 pub local_first: bool,
155
156 #[serde(default)]
160 pub local_endpoint: Option<String>,
161}
162
163impl Default for PawanConfig {
164 fn default() -> Self {
165 let mut targets = HashMap::new();
166 targets.insert(
167 "self".to_string(),
168 TargetConfig {
169 path: PathBuf::from("."),
170 description: "Current project codebase".to_string(),
171 },
172 );
173
174 Self {
175 provider: LlmProvider::Nvidia,
176 config_version: default_config_version(),
177 model: crate::DEFAULT_MODEL.to_string(),
178 base_url: None,
179 dry_run: false,
180 auto_backup: true,
181 require_git_clean: false,
182 bash_timeout_secs: crate::DEFAULT_BASH_TIMEOUT,
183 tool_call_idle_timeout_secs: default_tool_idle_timeout(),
184 max_file_size_kb: 1024,
185 max_tool_iterations: crate::MAX_TOOL_ITERATIONS,
186 max_context_tokens: 100000,
187 system_prompt: None,
188 temperature: 1.0,
189 top_p: 0.95,
190 max_tokens: 8192,
191 thinking_budget: 0, reasoning_mode: true,
193 max_retries: 3,
194 fallback_models: Vec::new(),
195 max_result_chars: 8000,
196 healing: HealingConfig::default(),
197 targets,
198 tui: TuiConfig::default(),
199 mcp: HashMap::new(),
200 permissions: HashMap::new(),
201 cloud: None,
202 models: ModelRouting::default(),
203 eruka: crate::eruka_bridge::ErukaConfig::default(),
204 use_ares_backend: false,
205 use_coordinator: false,
206 skills_repo: None,
207 local_first: false,
208 local_endpoint: None,
209 }
210 }
211}
212
213impl PawanConfig {
214 pub fn load(path: Option<&PathBuf>) -> crate::Result<Self> {
216 let config_path = path.cloned().or_else(|| {
217 let pawan_toml = PathBuf::from("pawan.toml");
219 if pawan_toml.exists() {
220 return Some(pawan_toml);
221 }
222
223 let ares_toml = PathBuf::from("ares.toml");
225 if ares_toml.exists() {
226 return Some(ares_toml);
227 }
228
229 if let Some(home) = dirs::home_dir() {
231 let global = home.join(".config/pawan/pawan.toml");
232 if global.exists() {
233 return Some(global);
234 }
235 }
236
237 None
238 });
239
240 match config_path {
241 Some(path) => {
242 let content = std::fs::read_to_string(&path).map_err(|e| {
243 crate::PawanError::Config(format!("Failed to read {}: {}", path.display(), e))
244 })?;
245
246 if path.file_name().map(|n| n == "ares.toml").unwrap_or(false) {
248 let value: toml::Value = toml::from_str(&content).map_err(|e| {
250 crate::PawanError::Config(format!(
251 "Failed to parse {}: {}",
252 path.display(),
253 e
254 ))
255 })?;
256
257 if let Some(pawan_section) = value.get("pawan") {
258 let config: PawanConfig =
259 pawan_section.clone().try_into().map_err(|e| {
260 crate::PawanError::Config(format!(
261 "Failed to parse [pawan] section: {}",
262 e
263 ))
264 })?;
265 return Ok(config);
266 }
267
268 Ok(Self::default())
270 } else {
271 let mut config: PawanConfig = toml::from_str(&content).map_err(|e| {
273 crate::PawanError::Config(format!(
274 "Failed to parse {}: {}",
275 path.display(),
276 e
277 ))
278 })?;
279
280 let migration_result = migrate_to_latest(&mut config, Some(&path));
282 if migration_result.migrated {
283 tracing::info!(
284 from_version = migration_result.from_version,
285 to_version = migration_result.to_version,
286 backup = ?migration_result.backup_path,
287 "Config migrated"
288 );
289
290 if let Err(e) = save_config(&config, &path) {
292 tracing::warn!(error = %e, "Failed to save migrated config");
293 }
294 }
295
296 Ok(config)
297 }
298 }
299 None => Ok(Self::default()),
300 }
301 }
302
303 pub fn apply_env_overrides(&mut self) {
305 if let Ok(model) = std::env::var("PAWAN_MODEL") {
306 self.model = model;
307 }
308 if let Ok(provider) = std::env::var("PAWAN_PROVIDER") {
309 match provider.to_lowercase().as_str() {
310 "nvidia" | "nim" => self.provider = LlmProvider::Nvidia,
311 "ollama" => self.provider = LlmProvider::Ollama,
312 "openai" => self.provider = LlmProvider::OpenAI,
313 "mlx" | "mlx-lm" => self.provider = LlmProvider::Mlx,
314 _ => tracing::warn!(
315 provider = provider.as_str(),
316 "Unknown PAWAN_PROVIDER, ignoring"
317 ),
318 }
319 }
320 if let Ok(temp) = std::env::var("PAWAN_TEMPERATURE") {
321 if let Ok(t) = temp.parse::<f32>() {
322 self.temperature = t;
323 }
324 }
325 if let Ok(tokens) = std::env::var("PAWAN_MAX_TOKENS") {
326 if let Ok(t) = tokens.parse::<usize>() {
327 self.max_tokens = t;
328 }
329 }
330 if let Ok(iters) = std::env::var("PAWAN_MAX_ITERATIONS") {
331 if let Ok(i) = iters.parse::<usize>() {
332 self.max_tool_iterations = i;
333 }
334 }
335 if let Ok(ctx) = std::env::var("PAWAN_MAX_CONTEXT_TOKENS") {
336 if let Ok(c) = ctx.parse::<usize>() {
337 self.max_context_tokens = c;
338 }
339 }
340 if let Ok(models) = std::env::var("PAWAN_FALLBACK_MODELS") {
341 self.fallback_models = models
342 .split(',')
343 .map(|s| s.trim().to_string())
344 .filter(|s| !s.is_empty())
345 .collect();
346 }
347 if let Ok(chars) = std::env::var("PAWAN_MAX_RESULT_CHARS") {
348 if let Ok(c) = chars.parse::<usize>() {
349 self.max_result_chars = c;
350 }
351 }
352 }
353
354 pub fn get_target(&self, name: &str) -> Option<&TargetConfig> {
356 self.targets.get(name)
357 }
358
359 pub fn get_system_prompt(&self) -> String {
362 match self.get_system_prompt_checked() {
363 Ok(p) => p,
364 Err(e) => {
365 tracing::error!("Failed to load project context for system prompt: {}", e);
366 self.system_prompt
367 .clone()
368 .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string())
369 }
370 }
371 }
372
373 pub fn get_system_prompt_checked(&self) -> crate::Result<String> {
376 let base = self
377 .system_prompt
378 .clone()
379 .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string());
380
381 let mut prompt = base;
382
383 if let Some((filename, ctx)) = Self::load_context_file()? {
384 prompt = format!(
385 "{}
386
387## Project Context (from {})
388
389{}",
390 prompt, filename, ctx
391 );
392 }
393
394 if let Some(skill_ctx) = Self::load_skill_context() {
395 prompt = format!(
396 "{}
397
398## Active Skill (from SKILL.md)
399
400{}",
401 prompt, skill_ctx
402 );
403 }
404
405 #[cfg(feature = "memory")]
406 {
407 if let Ok(store) = crate::memory::MemoryStore::new_default() {
408 prompt = crate::memory::inject_memory_guidance_into_prompt(prompt, &store);
409 }
410 }
411
412 Ok(prompt)
413 }
414
415 fn scan_context_file(content: &str, source: &str) -> crate::Result<String> {
416 let suspicious = [
418 "IGNORE ALL PREVIOUS",
419 "DISREGARD ALL",
420 "OVERRIDE",
421 "You are now",
422 "Your new role",
423 "IMPORTANT: do not",
424 "<system-directive>",
425 "<role>",
426 "<contract>",
427 "\u{200B}",
429 "\u{200C}",
430 "\u{200D}",
431 "\u{FEFF}",
432 "\u{202E}",
433 "\u{2060}",
434 "\u{2061}",
435 "\u{2062}",
436 ];
437
438 let upper = content.to_uppercase();
439 let allow = source == "AGENTS.md" || source == "CLAUDE.md";
440
441 for pattern in &suspicious {
442 let hit = if pattern.is_ascii() {
443 upper.contains(&pattern.to_uppercase())
444 } else {
445 content.contains(pattern)
446 };
447
448 if hit {
449 tracing::warn!(source = %source, pattern = %pattern, "prompt injection pattern detected");
450 if allow {
451 continue;
452 }
453 return Err(crate::PawanError::Config(format!(
454 "Suspicious content in {}: contains '{}'",
455 source, pattern
456 )));
457 }
458 }
459
460 Ok(content.to_string())
461 }
462
463 fn load_context_file() -> crate::Result<Option<(String, String)>> {
467 for path in &["PAWAN.md", "AGENTS.md", "CLAUDE.md", ".pawan/context.md"] {
468 let p = PathBuf::from(path);
469 if p.exists() {
470 let bytes = std::fs::read(&p).map_err(crate::PawanError::Io)?;
471 let content = String::from_utf8(bytes).map_err(|_| {
472 crate::PawanError::Config(format!(
473 "Suspicious content in {}: file is not valid UTF-8 (binary?)",
474 path
475 ))
476 })?;
477
478 let content = Self::scan_context_file(&content, path)?;
479 if !content.trim().is_empty() {
480 return Ok(Some((path.to_string(), content)));
481 }
482 }
483 }
484 Ok(None)
485 }
486
487 fn load_skill_context() -> Option<String> {
491 use thulp_skill_files::SkillFile;
492
493 let skill_path = std::path::Path::new("SKILL.md");
494 if !skill_path.exists() {
495 return None;
496 }
497
498 match SkillFile::parse(skill_path) {
499 Ok(skill) => {
500 let name = skill.effective_name();
501 let desc = skill
502 .frontmatter
503 .description
504 .as_deref()
505 .unwrap_or("no description");
506 let tools_str = match &skill.frontmatter.allowed_tools {
507 Some(tools) => tools.join(", "),
508 None => "all".to_string(),
509 };
510 Some(format!(
511 "[Skill: {}] {}\nAllowed tools: {}\n---\n{}",
512 name, desc, tools_str, skill.content
513 ))
514 }
515 Err(e) => {
516 tracing::warn!("Failed to parse SKILL.md: {}", e);
517 None
518 }
519 }
520 }
521
522 pub fn resolve_skills_repo(&self) -> Option<PathBuf> {
529 if let Ok(env_path) = std::env::var("PAWAN_SKILLS_REPO") {
531 let p = PathBuf::from(env_path);
532 if p.is_dir() {
533 return Some(p);
534 }
535 tracing::warn!(path = %p.display(), "PAWAN_SKILLS_REPO set but directory does not exist");
536 }
537
538 if let Some(ref p) = self.skills_repo {
540 if p.is_dir() {
541 return Some(p.clone());
542 }
543 tracing::warn!(path = %p.display(), "config.skills_repo set but directory does not exist");
544 }
545
546 if let Some(home) = dirs::home_dir() {
548 let default = home.join(".config").join("pawan").join("skills");
549 if default.is_dir() {
550 return Some(default);
551 }
552 }
553
554 None
555 }
556
557 pub fn auto_discover_mcp_servers(&mut self) -> Vec<String> {
568 let mut discovered = Vec::new();
569
570 if !self.mcp.contains_key("eruka") && which::which("eruka-mcp").is_ok() {
572 self.mcp.insert(
573 "eruka".to_string(),
574 McpServerEntry {
575 command: "eruka-mcp".to_string(),
576 args: vec!["--transport".to_string(), "stdio".to_string()],
577 env: HashMap::new(),
578 enabled: true,
579 },
580 );
581 discovered.push("eruka".to_string());
582 tracing::info!("auto-discovered eruka-mcp");
583 }
584
585 if !self.mcp.contains_key("daedra") && which::which("daedra").is_ok() {
587 self.mcp.insert(
588 "daedra".to_string(),
589 McpServerEntry {
590 command: "daedra".to_string(),
591 args: vec![
592 "serve".to_string(),
593 "--transport".to_string(),
594 "stdio".to_string(),
595 "--quiet".to_string(),
596 ],
597 env: HashMap::new(),
598 enabled: true,
599 },
600 );
601 discovered.push("daedra".to_string());
602 tracing::info!("auto-discovered daedra");
603 }
604
605 if !self.mcp.contains_key("deagle") && which::which("deagle-mcp").is_ok() {
607 self.mcp.insert(
608 "deagle".to_string(),
609 McpServerEntry {
610 command: "deagle-mcp".to_string(),
611 args: vec!["--transport".to_string(), "stdio".to_string()],
612 env: HashMap::new(),
613 enabled: true,
614 },
615 );
616 discovered.push("deagle".to_string());
617 tracing::info!("auto-discovered deagle-mcp");
618 }
619
620 discovered
621 }
622
623 pub fn discover_skills_from_repo(&self) -> Vec<(String, String, PathBuf)> {
634 use thulp_skill_files::SkillFile;
635
636 let repo = match self.resolve_skills_repo() {
637 Some(r) => r,
638 None => return Vec::new(),
639 };
640
641 let mut results = Vec::new();
642 let walker = match std::fs::read_dir(&repo) {
643 Ok(w) => w,
644 Err(e) => {
645 tracing::warn!(path = %repo.display(), error = %e, "failed to read skills repo");
646 return Vec::new();
647 }
648 };
649
650 for entry in walker.flatten() {
651 let path = entry.path();
652 let skill_file = path.join("SKILL.md");
654 if !skill_file.is_file() {
655 continue;
656 }
657 match SkillFile::parse(&skill_file) {
658 Ok(skill) => {
659 let name = skill.effective_name();
660 let desc = skill
661 .frontmatter
662 .description
663 .clone()
664 .unwrap_or_else(|| "(no description)".to_string());
665 results.push((name, desc, skill_file));
666 }
667 Err(e) => {
668 tracing::debug!(path = %skill_file.display(), error = %e, "skip unparseable skill");
669 }
670 }
671 }
672
673 results.sort_by(|a, b| a.0.cmp(&b.0));
674 results
675 }
676
677 pub fn use_thinking_mode(&self) -> bool {
680 self.reasoning_mode
681 && (self.model.contains("deepseek")
682 || self.model.contains("gemma")
683 || self.model.contains("glm")
684 || self.model.contains("qwen")
685 || self.model.contains("mistral-small-4"))
686 }
687}