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 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 pub fn reload(&mut self, entries: Vec<SkillEntry>) {
51 self.entries = entries;
52 }
53
54 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 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 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 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 catalog.reload(vec![
692 make_skill("xlsx", "spreadsheet skill"),
693 make_skill("slides", "presentation deck editing"),
694 ]);
695
696 assert_eq!(catalog.build_instructions("help with spreadsheet").len(), 1);
698 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}