Skip to main content

apollo/skills/
curator.rs

1//! Skill curator — background task that periodically reviews skills and
2//! suggests improvements based on usage patterns.
3//!
4//! Ported from hermes-agent curator pattern. Runs as a spawned tokio task
5//! that wakes on interval and scans skill directories for stale/improvable
6//! content.
7
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::time::Duration;
11
12use crate::skills::Skill;
13
14/// Configuration for the curator background task.
15#[derive(Debug, Clone)]
16pub struct CuratorConfig {
17    pub enabled: bool,
18    pub interval_hours: u64,
19    pub min_idle_hours: u64,
20    pub stale_after_days: u64,
21    pub archive_after_days: u64,
22    pub max_suggestions_per_run: usize,
23}
24
25impl Default for CuratorConfig {
26    fn default() -> Self {
27        Self {
28            enabled: false,
29            interval_hours: 24,
30            min_idle_hours: 2,
31            stale_after_days: 90,
32            archive_after_days: 180,
33            max_suggestions_per_run: 3,
34        }
35    }
36}
37
38/// A suggestion for improving a skill.
39#[derive(Debug, Clone)]
40pub struct SkillSuggestion {
41    pub skill_name: String,
42    pub kind: SuggestionKind,
43    pub reason: String,
44    pub detail: String,
45}
46
47#[derive(Debug, Clone)]
48pub enum SuggestionKind {
49    Archive,
50    Update,
51    MissingDeps,
52    BrokenRefs,
53}
54
55/// The curator runs as a background task.
56pub struct SkillCurator {
57    config: CuratorConfig,
58}
59
60impl SkillCurator {
61    pub fn new(config: CuratorConfig, _workspace: PathBuf) -> Self {
62        Self { config }
63    }
64
65    /// Spawn the background curation loop.
66    pub fn spawn(self, skills: Arc<std::sync::RwLock<Vec<Skill>>>) {
67        if !self.config.enabled {
68            tracing::info!("Skill curator disabled");
69            return;
70        }
71
72        let interval = Duration::from_secs(self.config.interval_hours * 3600);
73        tracing::info!(
74            "Skill curator started — every {} hours",
75            self.config.interval_hours
76        );
77
78        tokio::spawn(async move {
79            let mut tick = tokio::time::interval(interval);
80            tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
81
82            loop {
83                tick.tick().await;
84                let suggestions = Self::audit_skills(&skills, &self.config);
85                if suggestions.is_empty() {
86                    continue;
87                }
88                for s in &suggestions {
89                    tracing::info!(
90                        "[curator] {:?} skill '{}': {}",
91                        s.kind,
92                        s.skill_name,
93                        s.reason
94                    );
95                }
96                // Inject suggestions into the skill manager for review
97                if let Ok(mut skills_lock) = skills.write() {
98                    for s in &suggestions {
99                        Self::apply_suggestion(&mut skills_lock, s);
100                    }
101                }
102            }
103        });
104    }
105
106    /// Scan skills for issues.
107    fn audit_skills(
108        skills: &Arc<std::sync::RwLock<Vec<Skill>>>,
109        config: &CuratorConfig,
110    ) -> Vec<SkillSuggestion> {
111        let mut suggestions = Vec::new();
112        let skills_lock = match skills.read() {
113            Ok(s) => s,
114            Err(_) => return suggestions,
115        };
116
117        let now = std::time::SystemTime::now();
118
119        for skill in skills_lock.iter() {
120            if suggestions.len() >= config.max_suggestions_per_run {
121                break;
122            }
123
124            // Check if SKILL.md is stale (not modified in N days)
125            if let Ok(metadata) = std::fs::metadata(&skill.location) {
126                if let Ok(modified) = metadata.modified() {
127                    let age = now.duration_since(modified).ok();
128                    if let Some(age) = age {
129                        let days = age.as_secs() / 86400;
130                        if days >= config.archive_after_days {
131                            suggestions.push(SkillSuggestion {
132                                skill_name: skill.name.clone(),
133                                kind: SuggestionKind::Archive,
134                                reason: format!("Not modified in {} days", days),
135                                detail: "Consider archiving unused skill".to_string(),
136                            });
137                        } else if days >= config.stale_after_days {
138                            suggestions.push(SkillSuggestion {
139                                skill_name: skill.name.clone(),
140                                kind: SuggestionKind::Update,
141                                reason: format!("Stale — {} days since last change", days),
142                                detail: "Review and update skill content".to_string(),
143                            });
144                        }
145                    }
146                }
147            }
148
149            // Check for empty description
150            if skill.description.is_empty() {
151                suggestions.push(SkillSuggestion {
152                    skill_name: skill.name.clone(),
153                    kind: SuggestionKind::Update,
154                    reason: "Empty description".to_string(),
155                    detail: "Add a meaningful description for skill matching".to_string(),
156                });
157            }
158        }
159
160        suggestions
161    }
162
163    /// Apply a suggestion — for now just log; future: archive/flag skills.
164    fn apply_suggestion(_skills: &mut Vec<Skill>, s: &SkillSuggestion) {
165        match s.kind {
166            SuggestionKind::Archive => {
167                tracing::warn!(
168                    "[curator] Skill '{}' candidate for archival: {}",
169                    s.skill_name,
170                    s.reason
171                );
172            }
173            _ => {
174                tracing::info!(
175                    "[curator] Skill '{}' improvement needed: {}",
176                    s.skill_name,
177                    s.reason
178                );
179            }
180        }
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::skills::Skill;
188    use std::path::PathBuf;
189
190    fn make_skill(name: &str, description: &str) -> Skill {
191        Skill {
192            name: name.to_string(),
193            description: description.to_string(),
194            location: PathBuf::from("/tmp/test/SKILL.md"),
195        }
196    }
197
198    #[test]
199    fn test_audit_empty_descriptions() {
200        let skills = Arc::new(std::sync::RwLock::new(vec![
201            make_skill("good", "Has a description"),
202            make_skill("bad", ""),
203        ]));
204
205        let config = CuratorConfig {
206            max_suggestions_per_run: 10,
207            ..Default::default()
208        };
209
210        let suggestions = SkillCurator::audit_skills(&skills, &config);
211        let empty_desc = suggestions.iter().find(|s| s.skill_name == "bad");
212        assert!(empty_desc.is_some());
213        assert!(matches!(empty_desc.unwrap().kind, SuggestionKind::Update));
214
215        let good = suggestions.iter().find(|s| s.skill_name == "good");
216        assert!(good.is_none());
217    }
218}