Skip to main content

orchestral_runtime/skill/
mod.rs

1pub mod discovery;
2
3use orchestral_core::planner::SkillInstruction;
4use std::collections::BTreeSet;
5use std::path::PathBuf;
6
7const MAX_SKILL_KEYWORDS: usize = 10;
8const MAX_SKILL_HIGHLIGHTS: usize = 8;
9const MAX_SKILL_CARD_CHARS: usize = 1_600;
10const MIN_SKILL_MATCH_SCORE: usize = 12;
11
12#[derive(Debug, Clone)]
13pub struct SkillEntry {
14    pub name: String,
15    pub description: String,
16    pub instructions: String,
17    pub source_path: PathBuf,
18    pub scripts_dir: Option<PathBuf>,
19    /// Skill-local virtual-env python binary, auto-detected from `<skill_dir>/.venv/bin/python3`.
20    pub venv_python: Option<PathBuf>,
21}
22
23#[derive(Debug, Clone)]
24pub struct SkillCatalog {
25    entries: Vec<SkillEntry>,
26    max_active: usize,
27}
28
29impl SkillCatalog {
30    pub fn new(entries: Vec<SkillEntry>, max_active: usize) -> Self {
31        Self {
32            entries,
33            max_active,
34        }
35    }
36
37    pub fn entries(&self) -> &[SkillEntry] {
38        &self.entries
39    }
40
41    pub fn active_entries(&self) -> Vec<&SkillEntry> {
42        if self.max_active == 0 {
43            return Vec::new();
44        }
45
46        self.entries.iter().take(self.max_active).collect()
47    }
48
49    /// Reload entries from filesystem (hot update).
50    pub fn reload(&mut self, entries: Vec<SkillEntry>) {
51        self.entries = entries;
52    }
53
54    /// Return all skill names and descriptions for the planner catalog.
55    pub fn summaries(&self) -> Vec<(&str, &str)> {
56        self.entries
57            .iter()
58            .map(|e| (e.name.as_str(), e.description.as_str()))
59            .collect()
60    }
61
62    /// Look up a skill by exact name and return its full instructions.
63    pub fn get_instructions(&self, name: &str) -> Option<SkillInstruction> {
64        self.entries
65            .iter()
66            .find(|e| e.name == name)
67            .map(|entry| SkillInstruction {
68                skill_name: entry.name.clone(),
69                instructions: entry.instructions.clone(),
70                skill_path: Some(entry.source_path.to_string_lossy().to_string()),
71                scripts_dir: entry
72                    .scripts_dir
73                    .as_ref()
74                    .map(|p| p.to_string_lossy().to_string()),
75                venv_python: entry
76                    .venv_python
77                    .as_ref()
78                    .map(|p| p.to_string_lossy().to_string()),
79            })
80    }
81
82    pub fn build_instructions(&self, intent: &str) -> Vec<SkillInstruction> {
83        self.matched_entries(intent)
84            .into_iter()
85            .map(|entry| SkillInstruction {
86                skill_name: entry.name.clone(),
87                instructions: build_skill_card(entry),
88                skill_path: Some(entry.source_path.to_string_lossy().to_string()),
89                scripts_dir: entry
90                    .scripts_dir
91                    .as_ref()
92                    .map(|p| p.to_string_lossy().to_string()),
93                venv_python: entry
94                    .venv_python
95                    .as_ref()
96                    .map(|p| p.to_string_lossy().to_string()),
97            })
98            .collect()
99    }
100
101    fn matched_entries(&self, intent: &str) -> Vec<&SkillEntry> {
102        if self.max_active == 0 {
103            return Vec::new();
104        }
105
106        let mut scored = self
107            .entries
108            .iter()
109            .filter_map(|entry| {
110                let score = score_skill(intent, entry);
111                if score == 0 {
112                    None
113                } else {
114                    Some((entry, score))
115                }
116            })
117            .collect::<Vec<_>>();
118
119        scored.sort_by(|(left_entry, left_score), (right_entry, right_score)| {
120            right_score
121                .cmp(left_score)
122                .then_with(|| left_entry.name.cmp(&right_entry.name))
123        });
124
125        scored
126            .into_iter()
127            .take(self.max_active)
128            .map(|(entry, _score)| entry)
129            .collect()
130    }
131}
132
133fn score_skill(intent: &str, entry: &SkillEntry) -> usize {
134    let normalized_intent = normalize_text(intent);
135    if normalized_intent.is_empty() {
136        return 0;
137    }
138
139    let raw_intent = intent.to_ascii_lowercase();
140    let intent_tokens = expand_token_aliases(&tokenize(intent));
141    let name_tokens = meaningful_tokens(expand_token_aliases(&tokenize(&entry.name)));
142    let description_tokens = meaningful_tokens(expand_token_aliases(&tokenize(&entry.description)));
143    let keyword_tokens = expand_token_aliases(&extract_skill_keywords(entry));
144
145    let mut score = 0usize;
146    let name_overlap = overlap_count(&intent_tokens, &name_tokens);
147    let description_overlap = overlap_count(&intent_tokens, &description_tokens);
148    let keyword_overlap = overlap_count(&intent_tokens, &keyword_tokens);
149    let normalized_name = normalize_text(&entry.name);
150    if !normalized_name.is_empty() {
151        let exact_name = format!(" {} ", normalized_name);
152        let padded_intent = format!(" {} ", normalized_intent);
153        if padded_intent.contains(&exact_name)
154            || raw_intent.contains(&format!("${}", entry.name.to_ascii_lowercase()))
155        {
156            score += 1_000;
157        }
158    }
159
160    score += name_overlap * 80;
161    score += description_overlap * 18;
162    if score > 0 {
163        score += keyword_overlap * 10;
164    }
165
166    if score >= MIN_SKILL_MATCH_SCORE {
167        score
168    } else {
169        0
170    }
171}
172
173fn meaningful_tokens(mut tokens: BTreeSet<String>) -> BTreeSet<String> {
174    tokens.retain(|token| !is_generic_skill_token(token));
175    tokens
176}
177
178fn build_skill_card(entry: &SkillEntry) -> String {
179    let mut lines = Vec::new();
180
181    if !entry.description.trim().is_empty() {
182        lines.push(format!("summary: {}", entry.description.trim()));
183    }
184
185    let keywords = extract_skill_keywords(entry)
186        .into_iter()
187        .take(MAX_SKILL_KEYWORDS)
188        .collect::<Vec<_>>();
189    if !keywords.is_empty() {
190        lines.push(format!("keywords: {}", keywords.join(", ")));
191    }
192
193    let referenced_scripts = extract_referenced_scripts(&entry.instructions);
194    if !referenced_scripts.is_empty() {
195        lines.push(format!("scripts: {}", referenced_scripts.join(", ")));
196    }
197
198    let highlights = extract_skill_highlights(&entry.instructions);
199    if !highlights.is_empty() {
200        lines.push("highlights:".to_string());
201        for highlight in highlights.into_iter().take(MAX_SKILL_HIGHLIGHTS) {
202            lines.push(format!("- {}", highlight));
203        }
204    }
205
206    truncate_card(lines.join("\n"), MAX_SKILL_CARD_CHARS)
207}
208
209fn extract_referenced_scripts(instructions: &str) -> Vec<String> {
210    let mut scripts = BTreeSet::new();
211    for raw in instructions.split_whitespace() {
212        let candidate = raw
213            .trim_matches(|ch: char| {
214                matches!(
215                    ch,
216                    '`' | '"' | '\'' | '(' | ')' | '[' | ']' | ',' | ';' | ':'
217                )
218            })
219            .trim();
220        if !candidate.starts_with("scripts/") {
221            continue;
222        }
223        if !(candidate.ends_with(".py")
224            || candidate.ends_with(".sh")
225            || candidate.ends_with(".js")
226            || candidate.ends_with(".ts")
227            || candidate.ends_with(".rb"))
228        {
229            continue;
230        }
231        scripts.insert(candidate.to_string());
232    }
233    scripts.into_iter().collect()
234}
235
236fn extract_skill_keywords(entry: &SkillEntry) -> BTreeSet<String> {
237    let mut keywords = BTreeSet::new();
238    keywords.extend(tokenize(&entry.name));
239    keywords.extend(tokenize(&entry.description));
240
241    let mut in_code_block = false;
242    for line in entry.instructions.lines() {
243        let trimmed = line.trim();
244        if trimmed.starts_with("```") {
245            in_code_block = !in_code_block;
246            continue;
247        }
248        if in_code_block || trimmed.is_empty() {
249            continue;
250        }
251        if trimmed.starts_with('#')
252            || trimmed.starts_with('-')
253            || trimmed.starts_with('*')
254            || trimmed
255                .chars()
256                .next()
257                .map(|ch| ch.is_ascii_digit())
258                .unwrap_or(false)
259        {
260            keywords.extend(tokenize(trimmed));
261        }
262    }
263
264    keywords.retain(|token| !is_generic_skill_token(token));
265    keywords
266}
267
268fn extract_skill_highlights(instructions: &str) -> Vec<String> {
269    let mut highlights = Vec::new();
270    let mut seen = BTreeSet::new();
271    let mut in_code_block = false;
272
273    for line in instructions.lines() {
274        let trimmed = line.trim();
275        if trimmed.starts_with("```") {
276            in_code_block = !in_code_block;
277            continue;
278        }
279        if in_code_block || trimmed.is_empty() {
280            continue;
281        }
282
283        let candidate = match highlight_priority(trimmed) {
284            Some(text) => normalize_highlight(text),
285            None => continue,
286        };
287        if candidate.is_empty() || candidate.len() > 180 {
288            continue;
289        }
290        if seen.insert(candidate.clone()) {
291            highlights.push(candidate);
292        }
293        if highlights.len() >= MAX_SKILL_HIGHLIGHTS {
294            break;
295        }
296    }
297
298    highlights
299}
300
301fn highlight_priority(line: &str) -> Option<&str> {
302    let trimmed = line.trim();
303    if trimmed.starts_with('#') {
304        return Some(trimmed.trim_start_matches('#').trim());
305    }
306    if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
307        return Some(trimmed[2..].trim());
308    }
309    if trimmed
310        .split_once('.')
311        .map(|(head, _)| !head.is_empty() && head.chars().all(|ch| ch.is_ascii_digit()))
312        .unwrap_or(false)
313    {
314        let (_, tail) = trimmed.split_once('.').unwrap_or_default();
315        return Some(tail.trim());
316    }
317    if trimmed.contains("MUST")
318        || trimmed.contains("Do NOT")
319        || trimmed.contains("Always")
320        || trimmed.contains("Required")
321        || trimmed.contains("Workflow")
322    {
323        return Some(trimmed);
324    }
325    None
326}
327
328fn normalize_highlight(line: &str) -> String {
329    let mut text = line
330        .trim()
331        .trim_matches('*')
332        .trim_matches('`')
333        .replace("**", "")
334        .replace("__", "");
335    text = text.split_whitespace().collect::<Vec<_>>().join(" ");
336    text
337}
338
339fn truncate_card(mut card: String, max_chars: usize) -> String {
340    if card.len() <= max_chars {
341        return card;
342    }
343
344    while card.len() > max_chars && card.ends_with('\n') {
345        card.pop();
346    }
347    if card.len() <= max_chars {
348        return card;
349    }
350
351    let mut truncated = String::new();
352    for ch in card.chars() {
353        if truncated.len() + ch.len_utf8() > max_chars.saturating_sub(3) {
354            break;
355        }
356        truncated.push(ch);
357    }
358    truncated.push_str("...");
359    truncated
360}
361
362fn overlap_count(intent_tokens: &BTreeSet<String>, skill_tokens: &BTreeSet<String>) -> usize {
363    intent_tokens.intersection(skill_tokens).count()
364}
365
366fn tokenize(text: &str) -> BTreeSet<String> {
367    let mut tokens = BTreeSet::new();
368    let mut current = String::new();
369
370    for ch in text.chars() {
371        if ch.is_ascii_alphanumeric() {
372            current.push(ch.to_ascii_lowercase());
373            continue;
374        }
375        push_token(&mut tokens, &mut current);
376    }
377    push_token(&mut tokens, &mut current);
378    tokens
379}
380
381fn push_token(tokens: &mut BTreeSet<String>, current: &mut String) {
382    if current.len() < 3 {
383        current.clear();
384        return;
385    }
386    let token = current.clone();
387    tokens.insert(token.clone());
388    if token.ends_with('s') && token.len() > 4 {
389        tokens.insert(token.trim_end_matches('s').to_string());
390    }
391    current.clear();
392}
393
394fn expand_token_aliases(tokens: &BTreeSet<String>) -> BTreeSet<String> {
395    let mut expanded = tokens.clone();
396    for token in tokens {
397        for alias in alias_tokens(token) {
398            expanded.insert((*alias).to_string());
399        }
400    }
401    expanded
402}
403
404fn alias_tokens(token: &str) -> &'static [&'static str] {
405    match token {
406        "excel" | "xlsx" | "xlsm" | "spreadsheet" | "spreadsheets" | "workbook" | "workbooks"
407        | "worksheet" | "worksheets" | "sheet" | "sheets" | "table" | "tables" | "csv" | "tsv"
408        | "tabular" => &[
409            "excel",
410            "xlsx",
411            "spreadsheet",
412            "workbook",
413            "worksheet",
414            "tabular",
415            "csv",
416            "tsv",
417        ],
418        "presentation" | "presentations" | "deck" | "decks" | "slide" | "slides" | "ppt"
419        | "pptx" => &["presentation", "deck", "slides", "pptx"],
420        "install" | "installer" | "installation" | "setup" | "configure" => {
421            &["install", "installer", "setup"]
422        }
423        "create" | "creator" | "author" | "build" | "write" => {
424            &["create", "creator", "build", "write"]
425        }
426        "skill" | "skills" => &["skill"],
427        _ => &[],
428    }
429}
430
431fn normalize_text(text: &str) -> String {
432    text.chars()
433        .map(|ch| {
434            if ch.is_ascii_alphanumeric() {
435                ch.to_ascii_lowercase()
436            } else {
437                ' '
438            }
439        })
440        .collect::<String>()
441        .split_whitespace()
442        .collect::<Vec<_>>()
443        .join(" ")
444}
445
446fn is_generic_skill_token(token: &str) -> bool {
447    matches!(
448        token,
449        "skill"
450            | "skills"
451            | "doc"
452            | "docs"
453            | "this"
454            | "that"
455            | "with"
456            | "when"
457            | "where"
458            | "from"
459            | "into"
460            | "your"
461            | "their"
462            | "there"
463            | "have"
464            | "must"
465            | "should"
466            | "will"
467            | "using"
468            | "user"
469            | "users"
470            | "workflow"
471            | "common"
472            | "important"
473            | "overview"
474            | "quick"
475            | "start"
476            | "reference"
477            | "map"
478            | "output"
479            | "outputs"
480            | "input"
481            | "inputs"
482    )
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    fn make_skill(name: &str, description: &str) -> SkillEntry {
490        SkillEntry {
491            name: name.to_string(),
492            description: description.to_string(),
493            instructions: format!("instructions for {name}"),
494            source_path: PathBuf::from(format!("/tmp/{name}/SKILL.md")),
495            scripts_dir: None,
496            venv_python: None,
497        }
498    }
499
500    #[test]
501    fn test_skill_catalog_active_entries_returns_all_up_to_limit() {
502        let catalog = SkillCatalog::new(
503            vec![
504                make_skill("xlsx", "xlsx creation and formula recalc"),
505                make_skill("git", "git commit and branch operations"),
506            ],
507            3,
508        );
509
510        let active = catalog.active_entries();
511        assert_eq!(active.len(), 2);
512        assert_eq!(active[0].name, "xlsx");
513        assert_eq!(active[1].name, "git");
514    }
515
516    #[test]
517    fn test_skill_catalog_max_active() {
518        let catalog = SkillCatalog::new(
519            vec![
520                make_skill("a", "demo skill one"),
521                make_skill("b", "demo skill two"),
522                make_skill("c", "demo skill three"),
523            ],
524            2,
525        );
526
527        let active = catalog.active_entries();
528        assert_eq!(active.len(), 2);
529        assert_eq!(active[0].name, "a");
530        assert_eq!(active[1].name, "b");
531    }
532
533    #[test]
534    fn test_skill_catalog_zero_max_active_disables_loading() {
535        let catalog = SkillCatalog::new(vec![make_skill("xlsx", "spreadsheet skill")], 3);
536        let disabled = SkillCatalog::new(vec![make_skill("xlsx", "spreadsheet skill")], 0);
537
538        assert_eq!(catalog.active_entries().len(), 1);
539        assert!(disabled.active_entries().is_empty());
540    }
541
542    #[test]
543    fn test_build_instructions_uses_ranked_skill_card() {
544        let catalog = SkillCatalog::new(
545            vec![SkillEntry {
546                name: "xlsx".to_string(),
547                description: "spreadsheet skill for formulas and formatting".to_string(),
548                instructions:
549                    "# Workflow\n- Use openpyxl for formulas\n- Recalculate before handoff\n"
550                        .to_string(),
551                source_path: PathBuf::from("/tmp/xlsx/SKILL.md"),
552                scripts_dir: Some(PathBuf::from("/tmp/xlsx/scripts")),
553                venv_python: Some(PathBuf::from("/tmp/xlsx/.venv/bin/python3")),
554            }],
555            3,
556        );
557
558        let instructions = catalog.build_instructions("please help with spreadsheet data");
559        assert_eq!(instructions.len(), 1);
560        let first = &instructions[0];
561        assert_eq!(first.skill_name, "xlsx");
562        assert!(first
563            .instructions
564            .contains("summary: spreadsheet skill for formulas and formatting"));
565        assert!(first.instructions.contains("highlights:"));
566        assert!(first.instructions.contains("Use openpyxl for formulas"));
567        assert_eq!(first.skill_path.as_deref(), Some("/tmp/xlsx/SKILL.md"));
568        assert_eq!(first.scripts_dir.as_deref(), Some("/tmp/xlsx/scripts"));
569        assert_eq!(
570            first.venv_python.as_deref(),
571            Some("/tmp/xlsx/.venv/bin/python3")
572        );
573    }
574
575    #[test]
576    fn test_build_instructions_prefers_relevant_skill_matches() {
577        let catalog = SkillCatalog::new(
578            vec![
579                make_skill("slides", "presentation deck editing and export"),
580                make_skill("xlsx", "spreadsheet workbook editing and formula repair"),
581                make_skill("skill-installer", "install skills into the environment"),
582            ],
583            2,
584        );
585
586        let instructions = catalog.build_instructions("docs 下面有个 excel,把需要填的都填了");
587        assert_eq!(instructions.len(), 1);
588        assert_eq!(instructions[0].skill_name, "xlsx");
589    }
590
591    #[test]
592    fn test_build_instructions_does_not_match_generic_docs_skill_for_excel_intent() {
593        let catalog = SkillCatalog::new(
594            vec![SkillEntry {
595                name: "openai-docs".to_string(),
596                description: "Use when the user asks how to build with OpenAI products or APIs"
597                    .to_string(),
598                instructions: "# OpenAI Docs\n- Search official docs\n".to_string(),
599                source_path: PathBuf::from("/tmp/openai-docs/SKILL.md"),
600                scripts_dir: None,
601                venv_python: None,
602            }],
603            3,
604        );
605
606        assert!(catalog
607            .build_instructions("docs 目录下有一个excel,帮我填一下")
608            .is_empty());
609    }
610
611    #[test]
612    fn test_build_instructions_requires_name_or_description_relevance_for_keyword_matches() {
613        let catalog = SkillCatalog::new(
614            vec![
615                SkillEntry {
616                    name: "skill-creator".to_string(),
617                    description: "Guide for creating effective skills".to_string(),
618                    instructions: "# Workflow\n- Tool integrations\n- Domain expertise\n"
619                        .to_string(),
620                    source_path: PathBuf::from("/tmp/skill-creator/SKILL.md"),
621                    scripts_dir: None,
622                    venv_python: None,
623                },
624                make_skill("xlsx", "spreadsheet workbook editing and formula repair"),
625            ],
626            3,
627        );
628
629        let instructions = catalog.build_instructions("请把 excel 表格里的空白都填上");
630        assert_eq!(instructions.len(), 1);
631        assert_eq!(instructions[0].skill_name, "xlsx");
632    }
633
634    #[test]
635    fn test_build_instructions_respects_explicit_skill_name() {
636        let catalog = SkillCatalog::new(
637            vec![
638                make_skill("slides", "presentation deck editing and export"),
639                make_skill("xlsx", "spreadsheet workbook editing and formula repair"),
640            ],
641            2,
642        );
643
644        let instructions = catalog.build_instructions("请用 $slides skill 处理这个 deck");
645        assert_eq!(instructions.len(), 1);
646        assert_eq!(instructions[0].skill_name, "slides");
647    }
648
649    #[test]
650    fn test_build_instructions_skips_irrelevant_skills() {
651        let catalog = SkillCatalog::new(
652            vec![
653                make_skill("slides", "presentation deck editing and export"),
654                make_skill("xlsx", "spreadsheet workbook editing and formula repair"),
655            ],
656            2,
657        );
658
659        assert!(catalog
660            .build_instructions("帮我写一段 rust 单元测试")
661            .is_empty());
662    }
663
664    #[test]
665    fn test_skill_catalog_reload_updates_entries() {
666        let mut catalog = SkillCatalog::new(vec![make_skill("xlsx", "spreadsheet skill")], 3);
667        assert_eq!(catalog.entries().len(), 1);
668        assert_eq!(catalog.summaries().len(), 1);
669
670        // Reload with new entries
671        catalog.reload(vec![
672            make_skill("xlsx", "spreadsheet skill v2"),
673            make_skill("git", "git operations"),
674        ]);
675        assert_eq!(catalog.entries().len(), 2);
676        assert_eq!(catalog.summaries().len(), 2);
677        assert_eq!(catalog.entries()[0].description, "spreadsheet skill v2");
678
679        // Reload with empty — clears catalog
680        catalog.reload(vec![]);
681        assert!(catalog.entries().is_empty());
682        assert!(catalog.summaries().is_empty());
683    }
684
685    #[test]
686    fn test_skill_catalog_reload_preserves_matching() {
687        let mut catalog = SkillCatalog::new(vec![make_skill("xlsx", "spreadsheet skill")], 3);
688        assert_eq!(catalog.build_instructions("help with spreadsheet").len(), 1);
689
690        // Add a new skill via reload
691        catalog.reload(vec![
692            make_skill("xlsx", "spreadsheet skill"),
693            make_skill("slides", "presentation deck editing"),
694        ]);
695
696        // Old match still works
697        assert_eq!(catalog.build_instructions("help with spreadsheet").len(), 1);
698        // New skill is matchable
699        assert_eq!(
700            catalog
701                .build_instructions("create a presentation deck")
702                .len(),
703            1
704        );
705        assert_eq!(
706            catalog.build_instructions("create a presentation deck")[0].skill_name,
707            "slides"
708        );
709    }
710}