vtcode_core/skills/loader/
mentions.rs1use hashbrown::HashSet;
2
3use crate::skills::types::SkillManifest;
4
5#[derive(Debug, Clone, Copy)]
6pub struct SkillMentionDetectionOptions {
7 pub enable_auto_trigger: bool,
8 pub enable_description_matching: bool,
9 pub min_keyword_matches: usize,
10}
11
12impl Default for SkillMentionDetectionOptions {
13 fn default() -> Self {
14 Self {
15 enable_auto_trigger: true,
16 enable_description_matching: true,
17 min_keyword_matches: 2,
18 }
19 }
20}
21
22pub fn detect_skill_mentions(user_input: &str, available_skills: &[SkillManifest]) -> Vec<String> {
24 detect_skill_mentions_with_options(user_input, available_skills, &SkillMentionDetectionOptions::default())
25}
26
27pub fn detect_skill_mentions_with_options(
33 user_input: &str,
34 available_skills: &[SkillManifest],
35 options: &SkillMentionDetectionOptions,
36) -> Vec<String> {
37 if !options.enable_auto_trigger {
38 return Vec::new();
39 }
40
41 let mut mentions = Vec::new();
42 let input_lower = user_input.to_lowercase();
43 let input_keywords = extract_keywords(user_input);
44 let min_matches = options.min_keyword_matches.max(1);
45
46 for skill in available_skills {
47 let skill_name_lower = skill.name.to_lowercase();
48 let explicit_trigger = format!("${skill_name_lower}");
49 if input_lower.contains(&explicit_trigger) {
50 mentions.push(skill.name.clone());
51 continue;
52 }
53
54 if !options.enable_description_matching {
55 continue;
56 }
57
58 let skill_keywords = skill_routing_keywords(skill);
59 let keyword_matches = overlap_count(&input_keywords, &skill_keywords);
60 if keyword_matches >= min_matches {
61 mentions.push(skill.name.clone());
62 }
63 }
64
65 mentions.sort();
66 mentions.dedup();
67 mentions
68}
69
70fn overlap_count(input_keywords: &HashSet<String>, skill_keywords: &HashSet<String>) -> usize {
71 input_keywords.intersection(skill_keywords).count()
72}
73
74fn skill_routing_keywords(skill: &SkillManifest) -> HashSet<String> {
75 let mut keywords = extract_keywords(&skill.description);
76
77 let Some(metadata) = &skill.metadata else {
78 return keywords;
79 };
80 for metadata_keywords in [metadata.get("keywords"), metadata.get("tags")].into_iter().flatten() {
81 match metadata_keywords {
82 serde_json::Value::Array(values) => {
83 for value in values {
84 if let Some(keyword) = value.as_str() {
85 keywords.extend(extract_keywords(keyword));
86 }
87 }
88 }
89 serde_json::Value::String(value) => {
90 keywords.extend(extract_keywords(value));
91 }
92 _ => {}
93 }
94 }
95
96 keywords
97}
98
99fn extract_keywords(text: &str) -> HashSet<String> {
100 const STOPWORDS: &[&str] = &[
101 "the", "and", "with", "from", "that", "this", "when", "where", "what", "your", "for", "into", "onto", "than",
102 "then", "also", "only", "should", "would", "could", "have", "has", "had", "use", "using", "task", "tasks",
103 "help", "need", "want",
104 ];
105
106 text.split(|c: char| !c.is_alphanumeric())
107 .map(|part| part.trim().to_lowercase())
108 .filter(|part| part.len() > 2)
109 .filter(|part| !STOPWORDS.contains(&part.as_str()))
110 .collect()
111}