1pub mod access_control;
4pub mod context;
5pub mod resource_helpers;
6pub mod runtime_metadata;
7pub mod selection;
8pub mod session_port;
9pub mod store;
10pub mod types;
11
12pub use store::{SkillStore, SkillUpdate};
13pub use types::*;
14
15use std::collections::{BTreeSet, HashSet};
16use std::sync::Arc;
17
18const MAX_UNSELECTED_SKILLS_IN_CONTEXT: usize = 24;
19
20fn tokenize_request_hint(request_hint: &str) -> Vec<String> {
21 let mut seen = HashSet::new();
22 let mut tokens = Vec::new();
23
24 for token in request_hint
25 .split(|character: char| !character.is_ascii_alphanumeric() && character != '-')
26 .map(|token| token.trim().to_lowercase())
27 .filter(|token| token.len() >= 3)
28 {
29 if seen.insert(token.clone()) {
30 tokens.push(token);
31 }
32 }
33
34 tokens
35}
36
37fn skill_match_score(skill: &SkillDefinition, tokens: &[String]) -> usize {
38 if tokens.is_empty() {
39 return 0;
40 }
41
42 let searchable = format!(
43 "{} {} {} {}",
44 skill.id.to_lowercase(),
45 skill.name.to_lowercase(),
46 skill.description.to_lowercase(),
47 skill
48 .tool_refs
49 .iter()
50 .map(|tool| tool.to_lowercase())
51 .collect::<Vec<_>>()
52 .join(" ")
53 );
54
55 tokens
56 .iter()
57 .map(|token| {
58 if searchable.contains(token) {
59 if skill.id.to_lowercase().contains(token)
60 || skill.name.to_lowercase().contains(token)
61 {
62 3
63 } else {
64 1
65 }
66 } else {
67 0
68 }
69 })
70 .sum()
71}
72
73fn shortlist_skills_for_context(
74 mut skills: Vec<SkillDefinition>,
75 request_hint: Option<&str>,
76) -> Vec<SkillDefinition> {
77 if skills.len() <= MAX_UNSELECTED_SKILLS_IN_CONTEXT {
78 return skills;
79 }
80
81 let hint_tokens = request_hint
82 .map(str::trim)
83 .filter(|value| !value.is_empty())
84 .map(tokenize_request_hint)
85 .unwrap_or_default();
86
87 if hint_tokens.is_empty() {
88 skills.sort_by_key(|s| s.id.clone());
89 skills.truncate(MAX_UNSELECTED_SKILLS_IN_CONTEXT);
90 return skills;
91 }
92
93 let mut ranked: Vec<(usize, SkillDefinition)> = skills
94 .into_iter()
95 .map(|skill| (skill_match_score(&skill, &hint_tokens), skill))
96 .collect();
97
98 ranked.sort_by(|(left_score, left_skill), (right_score, right_skill)| {
99 right_score
100 .cmp(left_score)
101 .then_with(|| left_skill.id.cmp(&right_skill.id))
102 });
103
104 let mut selected = Vec::new();
105 let mut selected_ids = HashSet::new();
106
107 for (score, skill) in ranked.iter().cloned() {
108 if score == 0 || selected.len() >= MAX_UNSELECTED_SKILLS_IN_CONTEXT {
109 break;
110 }
111 selected_ids.insert(skill.id.clone());
112 selected.push(skill);
113 }
114
115 if selected.len() < MAX_UNSELECTED_SKILLS_IN_CONTEXT {
116 let mut fallback: Vec<SkillDefinition> = ranked
117 .into_iter()
118 .map(|(_, skill)| skill)
119 .filter(|skill| !selected_ids.contains(&skill.id))
120 .collect();
121 fallback.sort_by_key(|s| s.id.clone());
122 let remaining = MAX_UNSELECTED_SKILLS_IN_CONTEXT - selected.len();
123 selected.extend(fallback.into_iter().take(remaining));
124 }
125
126 selected.sort_by_key(|s| s.id.clone());
127 selected
128}
129
130fn filter_disabled_skills(
131 skills: Vec<SkillDefinition>,
132 disabled_skill_ids: &BTreeSet<String>,
133) -> Vec<SkillDefinition> {
134 if disabled_skill_ids.is_empty() {
135 return skills;
136 }
137
138 skills
139 .into_iter()
140 .filter(|skill| !disabled_skill_ids.contains(&skill.id))
141 .collect()
142}
143
144#[derive(Clone)]
146pub struct SkillManager {
147 store: Arc<SkillStore>,
148}
149
150impl SkillManager {
151 pub fn new() -> Self {
153 Self {
154 store: Arc::new(SkillStore::default()),
155 }
156 }
157
158 pub fn with_config(config: SkillStoreConfig) -> Self {
160 Self {
161 store: Arc::new(SkillStore::new(config)),
162 }
163 }
164
165 pub async fn initialize(&self) -> SkillResult<()> {
167 self.store.initialize().await
168 }
169
170 pub fn store(&self) -> &SkillStore {
172 &self.store
173 }
174
175 pub(crate) async fn list_skills_for_selection(
176 &self,
177 disabled_skill_ids: &BTreeSet<String>,
178 selected_skill_ids: Option<&[String]>,
179 selected_skill_mode: Option<&str>,
180 ) -> Vec<SkillDefinition> {
181 let skills = if selected_skill_mode.is_some() {
182 self.store
183 .list_skills_for_mode(None, selected_skill_mode)
184 .await
185 } else {
186 self.store.list_skills(None, true).await
187 };
188 let skills = filter_disabled_skills(skills, disabled_skill_ids);
189 let Some(selected_skill_ids) = selected_skill_ids else {
190 return skills;
191 };
192
193 let selected_set: HashSet<&str> = selected_skill_ids
194 .iter()
195 .map(|id| id.trim())
196 .filter(|id| !id.is_empty())
197 .collect();
198 if selected_set.is_empty() {
199 return skills;
200 }
201
202 let filtered: Vec<SkillDefinition> = skills
203 .into_iter()
204 .filter(|skill| selected_set.contains(skill.id.as_str()))
205 .collect();
206
207 if filtered.len() != selected_set.len() {
208 let missing: Vec<&str> = selected_set
209 .iter()
210 .copied()
211 .filter(|selected| !filtered.iter().any(|skill| skill.id == *selected))
212 .collect();
213 if !missing.is_empty() {
214 tracing::warn!(
215 "Some selected skills were not found on disk and will be ignored: {:?}",
216 missing
217 );
218 }
219 }
220
221 filtered
222 }
223
224 pub async fn build_skill_context_for_selection(
226 &self,
227 disabled_skill_ids: &BTreeSet<String>,
228 selected_skill_ids: Option<&[String]>,
229 ) -> String {
230 self.build_skill_context_for_request_with_mode(
231 disabled_skill_ids,
232 selected_skill_ids,
233 None,
234 None,
235 )
236 .await
237 }
238
239 pub async fn build_skill_context_for_selection_with_mode(
241 &self,
242 disabled_skill_ids: &BTreeSet<String>,
243 selected_skill_ids: Option<&[String]>,
244 selected_skill_mode: Option<&str>,
245 ) -> String {
246 self.build_skill_context_for_request_with_mode(
247 disabled_skill_ids,
248 selected_skill_ids,
249 selected_skill_mode,
250 None,
251 )
252 .await
253 }
254
255 pub async fn resolve_skills_for_request_with_mode(
256 &self,
257 disabled_skill_ids: &BTreeSet<String>,
258 selected_skill_ids: Option<&[String]>,
259 selected_skill_mode: Option<&str>,
260 request_hint: Option<&str>,
261 ) -> Vec<SkillDefinition> {
262 let mut skills = self
263 .list_skills_for_selection(disabled_skill_ids, selected_skill_ids, selected_skill_mode)
264 .await;
265
266 if selected_skill_ids.is_none() {
267 let original_len = skills.len();
268 skills = shortlist_skills_for_context(skills, request_hint);
269 if skills.len() < original_len {
270 tracing::info!(
271 "Skill context shortlisted from {} to {} entries (request_hint_present={})",
272 original_len,
273 skills.len(),
274 request_hint
275 .map(str::trim)
276 .is_some_and(|value| !value.is_empty())
277 );
278 }
279 }
280
281 skills
282 }
283
284 pub async fn build_skill_context_for_request_with_mode(
286 &self,
287 disabled_skill_ids: &BTreeSet<String>,
288 selected_skill_ids: Option<&[String]>,
289 selected_skill_mode: Option<&str>,
290 request_hint: Option<&str>,
291 ) -> String {
292 let skills = self
293 .resolve_skills_for_request_with_mode(
294 disabled_skill_ids,
295 selected_skill_ids,
296 selected_skill_mode,
297 request_hint,
298 )
299 .await;
300
301 tracing::info!(
302 "Building skill context with {} skill(s), selection_mode={}, skill_mode={}",
303 skills.len(),
304 if selected_skill_ids.is_some() {
305 "selected"
306 } else {
307 "all"
308 },
309 selected_skill_mode.unwrap_or("default"),
310 );
311 context::build_skill_context(&skills)
312 }
313
314 pub async fn build_skill_context(
316 &self,
317 disabled_skill_ids: &BTreeSet<String>,
318 _chat_id: Option<&str>,
319 ) -> String {
320 self.build_skill_context_for_selection(disabled_skill_ids, None)
321 .await
322 }
323
324 pub async fn get_allowed_tools_for_selection(
326 &self,
327 disabled_skill_ids: &BTreeSet<String>,
328 selected_skill_ids: Option<&[String]>,
329 ) -> Vec<String> {
330 self.get_allowed_tools_for_selection_with_mode(disabled_skill_ids, selected_skill_ids, None)
331 .await
332 }
333
334 pub async fn get_allowed_tools_for_selection_with_mode(
336 &self,
337 disabled_skill_ids: &BTreeSet<String>,
338 selected_skill_ids: Option<&[String]>,
339 selected_skill_mode: Option<&str>,
340 ) -> Vec<String> {
341 let skills = self
342 .list_skills_for_selection(disabled_skill_ids, selected_skill_ids, selected_skill_mode)
343 .await;
344
345 let mut tools: Vec<String> = skills
346 .into_iter()
347 .flat_map(|skill| skill.tool_refs)
348 .collect::<HashSet<_>>()
349 .into_iter()
350 .collect();
351
352 tools.sort();
353 tools
354 }
355
356 pub async fn get_allowed_tools(
358 &self,
359 disabled_skill_ids: &BTreeSet<String>,
360 _chat_id: Option<&str>,
361 ) -> Vec<String> {
362 self.get_allowed_tools_for_selection(disabled_skill_ids, None)
363 .await
364 }
365}
366
367impl Default for SkillManager {
368 fn default() -> Self {
369 Self::new()
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use std::collections::BTreeSet;
376
377 use super::{
378 filter_disabled_skills, shortlist_skills_for_context, tokenize_request_hint,
379 SkillDefinition,
380 };
381
382 fn demo_skill(id: &str, description: &str) -> SkillDefinition {
383 SkillDefinition::new(id, id, description, "prompt")
384 }
385
386 #[test]
387 fn tokenize_request_hint_dedupes_and_filters_short_tokens() {
388 let tokens = tokenize_request_hint("fix ui ui in app and api");
389 assert!(tokens.contains(&"fix".to_string()));
390 assert!(tokens.contains(&"app".to_string()));
391 assert!(tokens.contains(&"api".to_string()));
392 assert_eq!(
393 tokens.iter().filter(|token| token.as_str() == "ui").count(),
394 0
395 );
396 }
397
398 #[test]
399 fn shortlist_skills_for_context_prefers_request_matches() {
400 let mut skills = Vec::new();
401 for index in 0..30 {
402 skills.push(demo_skill(
403 &format!("skill-{index:02}"),
404 "generic helper skill",
405 ));
406 }
407 skills.push(demo_skill("react-optimizer", "react vite optimization"));
408
409 let shortlisted = shortlist_skills_for_context(skills, Some("optimize react vite build"));
410 assert!(shortlisted.len() <= 24);
411 assert!(shortlisted
412 .iter()
413 .any(|skill| skill.id == "react-optimizer"));
414 }
415
416 #[test]
417 fn filter_disabled_skills_removes_matching_skill_ids() {
418 let skills = vec![
419 demo_skill("pdf", "pdf helper"),
420 demo_skill("pptx", "ppt helper"),
421 ];
422 let disabled: BTreeSet<String> = ["pdf".to_string()].into_iter().collect();
423
424 let filtered = filter_disabled_skills(skills, &disabled);
425 let ids: Vec<&str> = filtered.iter().map(|skill| skill.id.as_str()).collect();
426
427 assert_eq!(ids, vec!["pptx"]);
428 }
429}