use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; /// Skill definition #[derive(Debug, Clone)] pub struct Skill { pub name: String, pub description: String, pub content: String, pub always: bool, pub path: Option, } struct SkillMarkdownMeta { name: Option, description: Option, always: Option, } #[derive(Clone)] struct SkillsState { loaded_skills: Vec, last_picobot_mtime: Option, last_agent_mtime: Option, last_load_time: SystemTime, } impl Default for SkillsState { fn default() -> Self { Self { loaded_skills: Vec::new(), last_picobot_mtime: None, last_agent_mtime: None, last_load_time: SystemTime::now(), } } } /// Skills loader - loads skills from multiple directories #[derive(Clone)] pub struct SkillsLoader { picobot_skills_dir: PathBuf, agent_skills_dir: PathBuf, state: Arc>, } impl SkillsLoader { /// Create a new loader with default paths pub fn new() -> Self { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); Self { picobot_skills_dir: home.join(".picobot/skills"), agent_skills_dir: home.join(".agent/skills"), state: Arc::new(Mutex::new(SkillsState::default())), } } #[cfg(test)] pub(crate) fn new_for_testing(picobot_dir: PathBuf, agent_dir: PathBuf) -> Self { Self { picobot_skills_dir: picobot_dir, agent_skills_dir: agent_dir, state: Arc::new(Mutex::new(SkillsState::default())), } } /// Load all skills from both directories and record modification times pub fn load_skills(&self) { let mut state = self.state.lock().unwrap(); state.loaded_skills.clear(); // Ensure ~/.picobot/skills directory exists if !self.picobot_skills_dir.exists() { if let Err(e) = std::fs::create_dir_all(&self.picobot_skills_dir) { tracing::warn!(dir = %self.picobot_skills_dir.display(), error = %e, "Failed to create skills directory"); } else { tracing::info!(dir = %self.picobot_skills_dir.display(), "Created skills directory"); } } // Load from ~/.picobot/skills if self.picobot_skills_dir.exists() { let loaded = self.load_skills_from_dir(&self.picobot_skills_dir); tracing::debug!( dir = %self.picobot_skills_dir.display(), count = loaded.len(), "Loaded skills from picobot directory" ); state.loaded_skills.extend(loaded); state.last_picobot_mtime = Self::get_dir_mtime(&self.picobot_skills_dir); } // Load from ~/.agent/skills if self.agent_skills_dir.exists() { let loaded = self.load_skills_from_dir(&self.agent_skills_dir); tracing::debug!( dir = %self.agent_skills_dir.display(), count = loaded.len(), "Loaded skills from agent directory" ); state.loaded_skills.extend(loaded); state.last_agent_mtime = Self::get_dir_mtime(&self.agent_skills_dir); } state.last_load_time = SystemTime::now(); if state.loaded_skills.is_empty() { tracing::debug!("No skills found in any skills directory"); } else { tracing::info!(count = state.loaded_skills.len(), "Loaded {} skills total", state.loaded_skills.len()); } } /// Check if skills directories have been modified since last load fn has_changed(&self) -> bool { let state = self.state.lock().unwrap(); let picobot_changed = if self.picobot_skills_dir.exists() { let current_mtime = Self::get_dir_mtime(&self.picobot_skills_dir); current_mtime != state.last_picobot_mtime } else { false }; let agent_changed = if self.agent_skills_dir.exists() { let current_mtime = Self::get_dir_mtime(&self.agent_skills_dir); current_mtime != state.last_agent_mtime } else { false }; picobot_changed || agent_changed } /// Reload skills if changes are detected pub fn reload_if_changed(&self) -> bool { if self.has_changed() { tracing::info!("Skills directories changed, reloading..."); self.load_skills(); true } else { false } } /// Get the latest modification time of a directory or any of its children fn get_dir_mtime(dir: &Path) -> Option { let mut max_mtime = None; if let Ok(metadata) = std::fs::metadata(dir) { if let Ok(mtime) = metadata.modified() { max_mtime = Some(mtime); } } if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if let Ok(metadata) = std::fs::metadata(&path) { if let Ok(mtime) = metadata.modified() { if max_mtime.map_or(true, |current| mtime > current) { max_mtime = Some(mtime); } } } } } max_mtime } /// Get a copy of loaded skills (checks for changes first) pub fn get_loaded_skills(&self) -> Vec { self.reload_if_changed(); let state = self.state.lock().unwrap(); state.loaded_skills.clone() } /// Get skills marked as always (checks for changes first) pub fn get_always_skills(&self) -> Vec { self.reload_if_changed(); let state = self.state.lock().unwrap(); state.loaded_skills.iter().filter(|s| s.always).cloned().collect() } /// Get a specific skill by name (checks for changes first) pub fn get_skill(&self, name: &str) -> Option { self.reload_if_changed(); let state = self.state.lock().unwrap(); state.loaded_skills.iter().find(|s| s.name == name).cloned() } /// List all skills (name + description) (checks for changes first) pub fn list_skills(&self) -> Vec<(String, String)> { self.reload_if_changed(); let state = self.state.lock().unwrap(); state.loaded_skills .iter() .map(|s| (s.name.clone(), s.description.clone())) .collect() } /// Build XML summary of all skills (for progressive disclosure) (checks for changes first) pub fn build_skills_summary(&self) -> String { self.reload_if_changed(); let state = self.state.lock().unwrap(); if state.loaded_skills.is_empty() { return String::new(); } let mut lines = vec!["".to_string()]; for skill in &state.loaded_skills { if skill.always { continue; } lines.push(" ".to_string()); lines.push(format!(" {}", escape_xml(&skill.name))); lines.push(format!( " {}", escape_xml(&skill.description) )); if let Some(path) = &skill.path { lines.push(format!(" {}", escape_xml(&path.to_string_lossy()))); } lines.push(" ".to_string()); } lines.push("".to_string()); lines.join("\n") } /// Build prompt for always-injected skills (checks for changes first) pub fn build_always_skills_prompt(&self) -> String { self.reload_if_changed(); let state = self.state.lock().unwrap(); let always_skills: Vec<_> = state.loaded_skills.iter().filter(|s| s.always).collect(); if always_skills.is_empty() { return String::new(); } let mut parts = Vec::new(); for skill in always_skills { parts.push(format!("## Skill: {}\n\n{}", skill.name, skill.content)); } parts.join("\n\n---\n\n") } /// Build full skills prompt combining always skills and summary (checks for changes first) pub fn build_skills_prompt(&self) -> String { self.reload_if_changed(); let state = self.state.lock().unwrap(); let mut prompt = String::new(); let always_skills: Vec<_> = state.loaded_skills.iter().filter(|s| s.always).collect(); if !always_skills.is_empty() { let mut parts = Vec::new(); for skill in always_skills { parts.push(format!("## Skill: {}\n\n{}", skill.name, skill.content)); } prompt.push_str(&parts.join("\n\n---\n\n")); prompt.push_str("\n\n"); } let has_other_skills = state.loaded_skills.iter().any(|s| !s.always); if has_other_skills { prompt.push_str("## Available Skills\n\n"); prompt.push_str("Skills teach the agent how to use specific capabilities. Use the `get_skill` tool to load a skill's full content when needed.\n\n"); let mut lines = vec!["".to_string()]; for skill in &state.loaded_skills { if skill.always { continue; } lines.push(" ".to_string()); lines.push(format!(" {}", escape_xml(&skill.name))); lines.push(format!( " {}", escape_xml(&skill.description) )); if let Some(path) = &skill.path { lines.push(format!(" {}", escape_xml(&path.to_string_lossy()))); } lines.push(" ".to_string()); } lines.push("".to_string()); prompt.push_str(&lines.join("\n")); } prompt } /// Load skills from a specific directory fn load_skills_from_dir(&self, dir: &Path) -> Vec { let mut skills = Vec::new(); let Ok(entries) = std::fs::read_dir(dir) else { tracing::warn!(dir = %dir.display(), "Failed to read skills directory"); return skills; }; for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let skill_file = path.join("SKILL.md"); if !skill_file.exists() { continue; } match std::fs::read_to_string(&skill_file) { Ok(content) => { match self.parse_skill(&path, &content) { Some(skill) => { tracing::debug!( skill = %skill.name, path = %skill_file.display(), always = skill.always, "Loaded skill" ); skills.push(skill); } None => { tracing::warn!( path = %skill_file.display(), "Failed to parse skill" ); } } } Err(e) => { tracing::warn!( path = %skill_file.display(), error = %e, "Failed to read skill file" ); } } } skills } /// Parse a skill from markdown content fn parse_skill(&self, dir: &Path, content: &str) -> Option { let (meta, body) = self.parse_skill_markdown(content); let name = meta.name.or_else(|| { dir.file_name() .and_then(|n| n.to_str()) .map(|s| s.to_string()) })?; let description = meta .description .unwrap_or_else(|| extract_description(&body)); Some(Skill { name, description, content: body, always: meta.always.unwrap_or(false), path: Some(dir.to_path_buf()), }) } /// Parse skill markdown, extracting frontmatter and body fn parse_skill_markdown(&self, content: &str) -> (SkillMarkdownMeta, String) { let normalized = content.replace("\r\n", "\n"); if let Some(stripped) = normalized.strip_prefix("---\n") { if let Some(idx) = stripped.find("\n---\n") { let frontmatter = stripped[..idx].to_string(); let body = stripped[idx + 5..].trim().to_string(); let meta = self.parse_frontmatter(&frontmatter); return (meta, body); } if let Some(frontmatter) = stripped.strip_suffix("\n---") { return (self.parse_frontmatter(frontmatter), String::new()); } } (SkillMarkdownMeta::default(), normalized) } /// Parse simple YAML-like frontmatter fn parse_frontmatter(&self, content: &str) -> SkillMarkdownMeta { let mut meta = SkillMarkdownMeta::default(); for line in content.lines() { let Some((key, val)) = line.split_once(':') else { continue; }; let key = key.trim(); let val = val.trim().trim_matches('"').trim_matches('\''); match key { "name" => meta.name = Some(val.to_string()), "description" => meta.description = Some(val.to_string()), "always" => { meta.always = match val.to_lowercase().as_str() { "true" | "1" | "yes" | "on" => Some(true), "false" | "0" | "no" | "off" => Some(false), _ => None, }; } _ => {} } } meta } } impl Default for SkillsLoader { fn default() -> Self { Self::new() } } impl Default for SkillMarkdownMeta { fn default() -> Self { Self { name: None, description: None, always: None, } } } /// Extract first non-empty, non-heading line as description fn extract_description(content: &str) -> String { content .lines() .find(|line| !line.starts_with('#') && !line.trim().is_empty()) .map(|l| l.trim().to_string()) .unwrap_or_else(|| "No description".to_string()) } /// Escape XML special characters fn escape_xml(s: &str) -> String { let mut result = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => result.push_str("&"), '<' => result.push_str("<"), '>' => result.push_str(">"), '"' => result.push_str("""), '\'' => result.push_str("'"), _ => result.push(c), } } result } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_skill_without_frontmatter() { let loader = SkillsLoader::new(); let content = "# My Skill\n\nThis is the content."; let (meta, body) = loader.parse_skill_markdown(content); assert!(meta.name.is_none()); assert!(meta.description.is_none()); assert!(body.contains("My Skill")); } #[test] fn test_parse_skill_with_frontmatter() { let loader = SkillsLoader::new(); let content = r#"--- name: test-skill description: A test skill always: true --- # Test Skill This is the content. "#; let (meta, body) = loader.parse_skill_markdown(content); assert_eq!(meta.name, Some("test-skill".to_string())); assert_eq!(meta.description, Some("A test skill".to_string())); assert_eq!(meta.always, Some(true)); assert!(body.contains("Test Skill")); } #[test] fn test_escape_xml() { assert_eq!(escape_xml("a & b"), "a & b"); assert_eq!(escape_xml(""), "<tag>"); assert_eq!(escape_xml("\"quote\""), ""quote""); } #[test] fn test_extract_description() { assert_eq!( extract_description("# Title\n\nFirst line of content."), "First line of content." ); assert_eq!(extract_description("# Title"), "No description"); } }