1use std::path::PathBuf;
9use std::sync::Arc;
10use std::time::Duration;
11
12use crate::skills::Skill;
13
14#[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#[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
55pub struct SkillCurator {
57 config: CuratorConfig,
58}
59
60impl SkillCurator {
61 pub fn new(config: CuratorConfig, _workspace: PathBuf) -> Self {
62 Self { config }
63 }
64
65 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 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 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 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 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 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}