Skip to main content

vtcode_core/skills/loader/
mentions.rs

1use 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
22/// Detect skill mentions using default routing options.
23pub 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
27/// Detect skill mentions using explicit routing options.
28///
29/// Routing policy:
30/// - Explicit `$skill-name` mentions always win.
31/// - Description and manifest metadata keywords provide the only implicit signals.
32pub 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}