1use anyhow::Result;
8use compact_str::CompactString;
9use std::{collections::BTreeMap, path::PathBuf};
10use tokio::sync::RwLock;
11use wcore::Hook;
12
13pub mod loader;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum SkillTier {
24 Bundled,
26 Managed,
28 Workspace,
30}
31
32#[derive(Debug, Clone)]
38pub struct Skill {
39 pub name: CompactString,
41 pub description: String,
43 pub license: Option<CompactString>,
45 pub compatibility: Option<CompactString>,
47 pub metadata: BTreeMap<CompactString, String>,
49 pub allowed_tools: Vec<CompactString>,
51 pub body: String,
53}
54
55#[derive(Debug, Clone)]
57struct IndexedSkill {
58 skill: Skill,
59 tier: SkillTier,
60 priority: u8,
61}
62
63#[derive(Debug, Clone)]
67pub struct SkillRegistry {
68 skills: Vec<IndexedSkill>,
69 tag_index: BTreeMap<CompactString, Vec<usize>>,
70 trigger_index: BTreeMap<CompactString, Vec<usize>>,
71}
72
73impl Default for SkillRegistry {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79impl SkillRegistry {
80 pub fn new() -> Self {
82 Self {
83 skills: Vec::new(),
84 tag_index: BTreeMap::new(),
85 trigger_index: BTreeMap::new(),
86 }
87 }
88
89 pub fn add(&mut self, skill: Skill, tier: SkillTier) {
91 let priority = skill
92 .metadata
93 .get("priority")
94 .and_then(|v| v.parse::<u8>().ok())
95 .unwrap_or(0);
96
97 let idx = self.skills.len();
98
99 if let Some(tags) = skill.metadata.get("tags") {
101 for tag in tags.split(',') {
102 let tag = tag.trim();
103 if !tag.is_empty() {
104 self.tag_index
105 .entry(CompactString::from(tag))
106 .or_default()
107 .push(idx);
108 }
109 }
110 }
111
112 if let Some(triggers) = skill.metadata.get("triggers") {
114 for trigger in triggers.split(',') {
115 let trigger = trigger.trim().to_lowercase();
116 if !trigger.is_empty() {
117 self.trigger_index
118 .entry(CompactString::from(trigger))
119 .or_default()
120 .push(idx);
121 }
122 }
123 }
124
125 self.skills.push(IndexedSkill {
126 skill,
127 tier,
128 priority,
129 });
130 }
131
132 pub fn find_by_tags(&self, tags: &[CompactString]) -> Vec<&Skill> {
134 let mut indices: Vec<usize> = tags
135 .iter()
136 .filter_map(|tag| self.tag_index.get(tag))
137 .flatten()
138 .copied()
139 .collect();
140
141 indices.sort_unstable();
142 indices.dedup();
143
144 indices.sort_by(|&a, &b| {
145 let sa = &self.skills[a];
146 let sb = &self.skills[b];
147 sb.tier
148 .cmp(&sa.tier)
149 .then_with(|| sb.priority.cmp(&sa.priority))
150 });
151
152 indices.iter().map(|&i| &self.skills[i].skill).collect()
153 }
154
155 pub fn find_by_trigger(&self, query: &str) -> Vec<&Skill> {
157 let query_lower = query.to_lowercase();
158 let mut indices: Vec<usize> = self
159 .trigger_index
160 .iter()
161 .filter(|(keyword, _)| query_lower.contains(keyword.as_str()))
162 .flat_map(|(_, idxs)| idxs.iter().copied())
163 .collect();
164
165 indices.sort_unstable();
166 indices.dedup();
167
168 indices.sort_by(|&a, &b| {
169 let sa = &self.skills[a];
170 let sb = &self.skills[b];
171 sb.tier
172 .cmp(&sa.tier)
173 .then_with(|| sb.priority.cmp(&sa.priority))
174 });
175
176 indices.iter().map(|&i| &self.skills[i].skill).collect()
177 }
178
179 pub fn skills(&self) -> Vec<&Skill> {
181 self.skills.iter().map(|s| &s.skill).collect()
182 }
183
184 pub fn len(&self) -> usize {
186 self.skills.len()
187 }
188
189 pub fn is_empty(&self) -> bool {
191 self.skills.is_empty()
192 }
193}
194
195pub struct SkillHandler {
203 skills_dir: PathBuf,
204 registry: RwLock<SkillRegistry>,
205}
206
207impl SkillHandler {
208 pub fn load(skills_dir: PathBuf) -> Result<Self> {
211 let registry = if skills_dir.exists() {
212 match loader::load_skills_dir(&skills_dir, SkillTier::Workspace) {
213 Ok(r) => {
214 tracing::info!("loaded {} skill(s)", r.len());
215 r
216 }
217 Err(e) => {
218 tracing::warn!("could not load skills from {}: {e}", skills_dir.display());
219 SkillRegistry::new()
220 }
221 }
222 } else {
223 SkillRegistry::new()
224 };
225 Ok(Self {
226 skills_dir,
227 registry: RwLock::new(registry),
228 })
229 }
230
231 pub async fn reload(&self) -> Result<usize> {
234 let registry = if self.skills_dir.exists() {
235 loader::load_skills_dir(&self.skills_dir, SkillTier::Workspace)?
236 } else {
237 SkillRegistry::new()
238 };
239 let count = registry.len();
240 *self.registry.write().await = registry;
241 Ok(count)
242 }
243
244 pub fn registry(&self) -> &RwLock<SkillRegistry> {
246 &self.registry
247 }
248}
249
250impl Hook for SkillHandler {
251 fn on_build_agent(&self, mut config: wcore::AgentConfig) -> wcore::AgentConfig {
252 if let Ok(skills) = self.registry.try_read() {
253 for skill in skills.find_by_tags(&config.skill_tags) {
254 if !skill.body.is_empty() {
255 config.system_prompt.push_str("\n\n");
256 config.system_prompt.push_str(&skill.body);
257 }
258 }
259 }
260 config
261 }
262}