1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use crate::memory::Memory;
6
7pub mod mcp;
8pub mod plugin;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Skill {
15 pub name: String,
16 pub description: String,
17 pub trigger: Vec<String>,
19 pub body: String,
21 #[serde(default)]
23 pub source_file: String,
24 #[serde(default)]
26 pub usage_count: u32,
27 #[serde(default)]
29 pub created_at: String,
30 #[serde(default = "default_score")]
32 pub score: f64,
33 #[serde(default)]
35 pub auto_generated: bool,
36 #[serde(default)]
38 pub references: Vec<String>,
39 #[serde(default)]
41 pub templates: Vec<String>,
42 #[serde(default)]
44 pub scripts: Vec<String>,
45 #[serde(default)]
47 pub assets: Vec<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SkillInvocation {
52 pub skill: Skill,
53 pub loaded_references: Vec<(String, String)>,
54 pub loaded_templates: Vec<(String, String)>,
55 pub loaded_scripts: Vec<(String, String)>,
56 pub loaded_assets: Vec<(String, String)>,
57}
58
59fn default_score() -> f64 {
60 0.5
61}
62
63impl Skill {
64 pub fn from_markdown(content: &str, source_file: &str) -> Option<Self> {
75 let mut name = String::new();
76 let mut description = String::new();
77 let mut trigger = Vec::new();
78 let mut body = String::new();
79 let mut references = Vec::new();
80 let mut templates = Vec::new();
81 let mut scripts = Vec::new();
82 let mut assets = Vec::new();
83 let mut in_body = false;
84
85 for line in content.lines() {
86 let trimmed = line.trim();
87
88 if trimmed.starts_with("# Skill:") || trimmed.starts_with("# ") {
89 name = trimmed
90 .trim_start_matches("# Skill:")
91 .trim_start_matches("# ")
92 .trim()
93 .to_string();
94 continue;
95 }
96
97 if trimmed.starts_with("**Trigger:**") || trimmed.starts_with("**Triggers:**") {
98 let trig_str = trimmed
99 .trim_start_matches("**Trigger:**")
100 .trim_start_matches("**Triggers:**")
101 .trim();
102 trigger = trig_str
103 .split(',')
104 .map(|s| s.trim().to_lowercase())
105 .filter(|s| !s.is_empty())
106 .collect();
107 continue;
108 }
109
110 if trimmed.starts_with("**Description:**") {
111 description = trimmed
112 .trim_start_matches("**Description:**")
113 .trim()
114 .to_string();
115 continue;
116 }
117 if trimmed.starts_with("**References:**") {
118 references = parse_csv_field(trimmed.trim_start_matches("**References:**"));
119 continue;
120 }
121 if trimmed.starts_with("**Templates:**") {
122 templates = parse_csv_field(trimmed.trim_start_matches("**Templates:**"));
123 continue;
124 }
125 if trimmed.starts_with("**Scripts:**") {
126 scripts = parse_csv_field(trimmed.trim_start_matches("**Scripts:**"));
127 continue;
128 }
129 if trimmed.starts_with("**Assets:**") {
130 assets = parse_csv_field(trimmed.trim_start_matches("**Assets:**"));
131 continue;
132 }
133
134 if trimmed == "## Body" || trimmed == "### Body" {
135 in_body = true;
136 continue;
137 }
138
139 if in_body {
140 body.push_str(line);
141 body.push('\n');
142 }
143 }
144
145 if name.is_empty() {
146 return None;
147 }
148
149 if body.is_empty() && !in_body {
150 body = content
152 .lines()
153 .skip_while(|l| !l.starts_with("**Trigger"))
154 .skip(1)
155 .collect::<Vec<_>>()
156 .join("\n");
157 }
158
159 Some(Skill {
160 name,
161 description: if description.is_empty() {
162 trigger.join(", ")
163 } else {
164 description
165 },
166 trigger,
167 body: body.trim().to_string(),
168 source_file: source_file.to_string(),
169 usage_count: 0,
170 created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
171 score: 0.5,
172 auto_generated: false,
173 references,
174 templates,
175 scripts,
176 assets,
177 })
178 }
179
180 pub fn to_markdown(&self) -> String {
182 format!(
183 "# Skill: {name}\n\n\
184 **Trigger:** {trigger}\n\n\
185 **Description:** {desc}\n\n\
186 **References:** {references}\n\n\
187 **Templates:** {templates}\n\n\
188 **Scripts:** {scripts}\n\n\
189 **Assets:** {assets}\n\n\
190 ## Body\n\
191 {body}\n",
192 name = self.name,
193 trigger = self.trigger.join(", "),
194 desc = self.description,
195 references = self.references.join(", "),
196 templates = self.templates.join(", "),
197 scripts = self.scripts.join(", "),
198 assets = self.assets.join(", "),
199 body = self.body,
200 )
201 }
202
203 pub fn relevance(&self, ctx: &str) -> f64 {
206 let lower = ctx.to_lowercase();
207 if self.trigger.is_empty() {
208 return 0.0;
209 }
210 let matches: usize = self
211 .trigger
212 .iter()
213 .filter(|kw| lower.contains(kw.as_str()))
214 .count();
215 if matches == 0 {
216 return 0.0;
217 }
218 matches as f64 / self.trigger.len() as f64
219 }
220}
221
222fn parse_csv_field(value: &str) -> Vec<String> {
223 value
224 .trim()
225 .split(',')
226 .map(|s| s.trim().to_string())
227 .filter(|s| !s.is_empty())
228 .collect()
229}
230
231pub trait SkillLibrary: Send + Sync {
234 fn relevant(&self, ctx: &str, limit: usize) -> Vec<Skill>;
235 fn add(&self, skill: Skill) -> anyhow::Result<()>;
236 fn all(&self) -> Vec<Skill>;
237 fn curate(&self) -> anyhow::Result<()>;
238 fn prune(&self, min_score: f64) -> anyhow::Result<usize>;
239 fn get(&self, name: &str) -> Option<Skill>;
240 fn invoke(&self, name: &str) -> anyhow::Result<Option<SkillInvocation>>;
241 fn remove(&self, name: &str) -> anyhow::Result<bool>;
243 fn skills_root(&self) -> Option<std::path::PathBuf> {
246 None
247 }
248}
249
250pub struct FsSkillLibrary {
253 skills_dir: PathBuf,
254 memory: Option<Arc<dyn Memory>>,
255}
256
257impl FsSkillLibrary {
258 pub fn new(skills_dir: PathBuf) -> Self {
259 std::fs::create_dir_all(&skills_dir).ok();
260 Self {
261 skills_dir,
262 memory: None,
263 }
264 }
265
266 pub fn with_memory(mut self, memory: Arc<dyn Memory>) -> Self {
267 self.memory = Some(memory);
268 self
269 }
270
271 pub fn skills_dir(&self) -> &std::path::Path {
275 &self.skills_dir
276 }
277
278 pub fn scan(&self) -> Vec<Skill> {
280 let mut skills = Vec::new();
281 if let Ok(entries) = std::fs::read_dir(&self.skills_dir) {
282 for entry in entries.flatten() {
283 let path = entry.path();
284 if path.is_dir() {
285 let skill_file = path.join("SKILL.md");
287 if skill_file.exists() {
288 if let Ok(content) = std::fs::read_to_string(&skill_file) {
289 let rel = path
290 .file_name()
291 .map(|n| n.to_string_lossy().to_string())
292 .unwrap_or_default();
293 if let Some(skill) = Skill::from_markdown(&content, &rel) {
294 skills.push(skill);
295 }
296 }
297 }
298 } else if path
299 .file_name()
300 .map(|n| n.to_string_lossy().to_lowercase().ends_with(".skill.md"))
301 .unwrap_or(false)
302 {
303 if let Ok(content) = std::fs::read_to_string(&path) {
304 let rel = path
305 .file_name()
306 .map(|n| n.to_string_lossy().to_string())
307 .unwrap_or_default();
308 if let Some(skill) = Skill::from_markdown(&content, &rel) {
309 skills.push(skill);
310 }
311 }
312 }
313 }
314 }
315 skills
316 }
317}
318
319impl SkillLibrary for FsSkillLibrary {
320 fn skills_root(&self) -> Option<std::path::PathBuf> {
321 Some(self.skills_dir.clone())
322 }
323
324 fn relevant(&self, ctx: &str, limit: usize) -> Vec<Skill> {
325 let mut scored: Vec<(f64, Skill)> = self
326 .scan()
327 .into_iter()
328 .map(|s| {
329 let r = s.relevance(ctx);
330 (r, s)
331 })
332 .filter(|(r, _)| *r > 0.0)
333 .collect();
334
335 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
336
337 scored.into_iter().take(limit).map(|(_, s)| s).collect()
338 }
339
340 fn add(&self, skill: Skill) -> anyhow::Result<()> {
341 let skill_dir = self.skills_dir.join(safe_skill_dir_name(&skill.name)?);
343 std::fs::create_dir_all(&skill_dir)?;
344
345 let skill_file = skill_dir.join("SKILL.md");
346 let content = skill.to_markdown();
347 std::fs::write(&skill_file, content)?;
348
349 if let Some(mem) = &self.memory {
351 let _ = mem.upsert_doc(crate::memory::WorkingDoc {
352 id: format!("skill-{}", skill.name),
353 title: format!("Skill: {}", skill.name),
354 content: skill.body.clone(),
355 updated_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
356 });
357 }
358
359 Ok(())
360 }
361
362 fn all(&self) -> Vec<Skill> {
363 self.scan()
364 }
365
366 fn curate(&self) -> anyhow::Result<()> {
367 let curator = Curator::new();
368 curator.curate(&self.skills_dir)
369 }
370
371 fn prune(&self, min_score: f64) -> anyhow::Result<usize> {
372 let skills = self.scan();
373 let mut removed = 0;
374
375 for skill in &skills {
376 if skill.score < min_score && skill.auto_generated {
377 let skill_dir = self.skills_dir.join(safe_skill_dir_name(&skill.name)?);
378 if skill_dir.exists() {
379 std::fs::remove_dir_all(&skill_dir)?;
380 removed += 1;
381 }
382 }
383 }
384
385 Ok(removed)
386 }
387
388 fn get(&self, name: &str) -> Option<Skill> {
389 self.scan().into_iter().find(|s| s.name == name)
390 }
391
392 fn invoke(&self, name: &str) -> anyhow::Result<Option<SkillInvocation>> {
393 let Some(skill) = self.get(name) else {
394 return Ok(None);
395 };
396 let base = if skill.source_file.ends_with(".skill.md") {
397 self.skills_dir.clone()
398 } else {
399 self.skills_dir.join(&skill.source_file)
400 };
401 let load_files = |files: &[String]| -> Vec<(String, String)> {
402 let mut loaded = Vec::new();
403 for f in files {
404 let Ok(candidate) = safe_relative_path(&base, f) else {
405 continue;
406 };
407 if let Ok(content) = std::fs::read_to_string(&candidate) {
408 loaded.push((f.clone(), content));
409 }
410 }
411 loaded
412 };
413 Ok(Some(SkillInvocation {
414 loaded_references: load_files(&skill.references),
415 loaded_templates: load_files(&skill.templates),
416 loaded_scripts: load_files(&skill.scripts),
417 loaded_assets: load_files(&skill.assets),
418 skill,
419 }))
420 }
421
422 fn remove(&self, name: &str) -> anyhow::Result<bool> {
423 let skill_dir = self.skills_dir.join(safe_skill_dir_name(name)?);
425 let existed = skill_dir.exists();
426 if existed {
427 std::fs::remove_dir_all(&skill_dir)?;
428 }
429 Ok(existed)
430 }
431}
432
433fn safe_skill_dir_name(name: &str) -> anyhow::Result<String> {
434 let trimmed = name.trim();
435 if trimmed.is_empty()
436 || trimmed.contains("..")
437 || trimmed.contains('/')
438 || trimmed.contains('\\')
439 || trimmed.contains(':')
440 {
441 anyhow::bail!("invalid skill name '{}'", name);
442 }
443 Ok(trimmed.to_string())
444}
445
446fn safe_relative_path(base: &Path, relative: &str) -> anyhow::Result<PathBuf> {
447 let rel = Path::new(relative);
448 if rel.is_absolute()
449 || rel
450 .components()
451 .any(|c| matches!(c, std::path::Component::ParentDir))
452 {
453 anyhow::bail!("skill reference escapes skill directory: {}", relative);
454 }
455 let candidate = base.join(rel);
456 let canonical_base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
457 let canonical_candidate = candidate
458 .canonicalize()
459 .unwrap_or_else(|_| candidate.to_path_buf());
460 if !canonical_candidate.starts_with(&canonical_base) || !canonical_candidate.exists() {
461 anyhow::bail!("skill reference outside base or missing: {}", relative);
462 }
463 Ok(canonical_candidate)
464}
465
466pub struct Curator {
469 min_score: f64,
470 max_skills: usize,
471}
472
473impl Curator {
474 pub fn new() -> Self {
475 Self {
476 min_score: 0.2,
477 max_skills: 100,
478 }
479 }
480
481 pub fn curate(&self, skills_dir: &Path) -> anyhow::Result<()> {
485 let library = FsSkillLibrary::new(skills_dir.to_path_buf());
486 let mut skills = library.all();
487
488 if skills.is_empty() {
489 return Ok(());
490 }
491
492 for skill in &mut skills {
494 skill.score += skill.usage_count as f64 * 0.05;
496 skill.score += (skill.body.len() as f64 / 5000.0).min(0.1);
498 skill.score = skill.score.min(1.0);
500 }
501
502 let mut merged = Vec::new();
504 let mut merged_indices = std::collections::HashSet::new();
505
506 for i in 0..skills.len() {
507 if merged_indices.contains(&i) {
508 continue;
509 }
510 let mut current = skills[i].clone();
511
512 for j in (i + 1)..skills.len() {
513 if merged_indices.contains(&j) {
514 continue;
515 }
516 let name_overlap = current.name
518 [..current.name.len().min(3).min(skills[j].name.len())]
519 == skills[j].name[..skills[j].name.len().min(3).min(current.name.len())];
520
521 let trigger_overlap = {
522 let a: std::collections::HashSet<_> = current.trigger.iter().cloned().collect();
523 let b: std::collections::HashSet<_> =
524 skills[j].trigger.iter().cloned().collect();
525 let intersection = a.intersection(&b).count();
526 let union = a.union(&b).count();
527 if union == 0 {
528 false
529 } else {
530 intersection as f64 / union as f64 > 0.5
531 }
532 };
533
534 if name_overlap || trigger_overlap {
535 current.body = format!("{}\n\n---\n\n{}", current.body, skills[j].body);
537 current.score = current.score.max(skills[j].score);
538 current.trigger.extend(skills[j].trigger.clone());
539 current.trigger.sort();
540 current.trigger.dedup();
541 merged_indices.insert(j);
542 }
543 }
544 merged.push(current);
545 }
546
547 merged.retain(|s| !s.auto_generated || s.score >= self.min_score);
549 merged.sort_by(|a, b| {
550 b.score
551 .partial_cmp(&a.score)
552 .unwrap_or(std::cmp::Ordering::Equal)
553 });
554 if merged.len() > self.max_skills {
555 merged.truncate(self.max_skills);
556 }
557
558 for skill in &merged {
561 let skill_dir = skills_dir.join(safe_skill_dir_name(&skill.name)?);
562 std::fs::create_dir_all(&skill_dir)?;
563 std::fs::write(skill_dir.join("SKILL.md"), skill.to_markdown())?;
564 }
565
566 tracing::info!(
567 "Curator: {} skills before → {} after (deduped {}, pruned {})",
568 skills.len(),
569 merged.len(),
570 skills.len() - merged_indices.len(),
571 skills.len() + merged_indices.len() - merged.len() - skills.len().min(merged.len()),
572 );
573
574 Ok(())
575 }
576
577 pub fn propose_skill(run_description: &str, outcome: &str) -> Option<Skill> {
580 let words: Vec<&str> = run_description.split_whitespace().collect();
581 let lower = run_description.to_lowercase();
582 let outcome_lower = outcome.to_lowercase();
583
584 if words.len() < 5 || outcome_lower.contains("error") {
585 return None;
586 }
587
588 let specificity_markers = [
589 "github.com",
590 "http",
591 "https",
592 "this ",
593 "that ",
594 "the file",
595 "my ",
596 "your ",
597 "2024",
598 "2025",
599 "2026",
600 ];
601 if specificity_markers
602 .iter()
603 .any(|marker| lower.contains(marker))
604 {
605 return None;
606 }
607 if words.iter().any(|word| {
608 let cleaned = word.trim_matches(|c: char| !c.is_alphanumeric());
609 cleaned
612 .chars()
613 .next()
614 .map(|c| c.is_uppercase())
615 .unwrap_or(false)
616 && cleaned.chars().count() > 12
617 }) {
618 return None;
619 }
620
621 let has_concrete_output = [
622 "diff", "fn ", "struct ", "impl ", "test", "fixed", "refactor", "added", "updated",
623 "created", "modified", "patch", "write", "edit", "return", "async", "pub ", "let ",
624 "const ", "mod ",
625 ]
626 .iter()
627 .any(|needle| outcome_lower.contains(needle));
628 if !has_concrete_output {
629 return None;
630 }
631
632 let name = skill_name_from_pattern(run_description)?.to_string();
633 let triggers = skill_triggers_for_pattern(&name);
634
635 Some(Skill {
636 name,
637 description: format!("Reusable pattern learned from: {}", run_description),
638 trigger: triggers,
639 body: format!(
640 "## Context\nTask: {}\n\n## Approach\n{}",
641 run_description, outcome
642 ),
643 source_file: String::new(),
644 usage_count: 0,
645 created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
646 score: 0.3,
647 auto_generated: true,
648 references: Vec::new(),
649 templates: Vec::new(),
650 scripts: Vec::new(),
651 assets: Vec::new(),
652 })
653 }
654
655 pub fn propose_skill_if_missing(
656 run_description: &str,
657 outcome: &str,
658 library: &dyn SkillLibrary,
659 ) -> Option<Skill> {
660 let candidate = Self::propose_skill(run_description, outcome)?;
661 if library.get(&candidate.name).is_some() {
662 None
663 } else {
664 Some(candidate)
665 }
666 }
667}
668
669pub fn skill_name_from_pattern(description: &str) -> Option<&'static str> {
670 let d = description.to_lowercase();
671 if d.contains("test") && (d.contains("add") || d.contains("write") || d.contains("fix")) {
672 return Some("write-and-fix-tests");
673 }
674 if d.contains("refactor") || d.contains("rename") || d.contains("extract") {
675 return Some("refactor-safely");
676 }
677 if d.contains("debug") || d.contains("error") || d.contains("panic") || d.contains("crash") {
678 return Some("debug-systematically");
679 }
680 if d.contains("document")
681 || d.contains("comment")
682 || d.contains("readme")
683 || d.contains("docstring")
684 {
685 return Some("write-docs");
686 }
687 if d.contains("secur") || d.contains("vulnerab") || d.contains("audit") {
688 return Some("security-audit");
689 }
690 if d.contains("performance") || d.contains("slow") || d.contains("optim") || d.contains("bench")
691 {
692 return Some("performance-profile");
693 }
694 if d.contains("upgrade") || d.contains("bump") || d.contains("depend") || d.contains("package")
695 {
696 return Some("upgrade-dependencies");
697 }
698 if d.contains("review") || d.contains("pr") || d.contains("pull request") || d.contains("diff")
699 {
700 return Some("code-review");
701 }
702 if d.contains("git") || d.contains("commit") || d.contains("branch") || d.contains("merge") {
703 return Some("git-workflow");
704 }
705 None
706}
707
708fn skill_triggers_for_pattern(name: &str) -> Vec<String> {
709 match name {
710 "write-and-fix-tests" => vec!["test", "unit", "fix", "assert"],
711 "refactor-safely" => vec!["refactor", "rename", "extract", "safe"],
712 "debug-systematically" => vec!["debug", "error", "panic", "crash"],
713 "write-docs" => vec!["document", "readme", "comment", "docstring"],
714 "security-audit" => vec!["security", "audit", "vulnerability", "safe"],
715 "performance-profile" => vec!["performance", "slow", "optimize", "bench"],
716 "upgrade-dependencies" => vec!["upgrade", "bump", "dependency", "package"],
717 "code-review" => vec!["review", "pr", "diff", "pull-request"],
718 "git-workflow" => vec!["git", "commit", "branch", "merge"],
719 _ => vec!["skill"],
720 }
721 .into_iter()
722 .map(String::from)
723 .collect()
724}
725
726impl Default for Curator {
727 fn default() -> Self {
728 Self::new()
729 }
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735
736 fn temp_dir(name: &str) -> PathBuf {
737 std::env::temp_dir().join(format!(
738 "sparrow-tier2-{name}-{}",
739 std::time::SystemTime::now()
740 .duration_since(std::time::UNIX_EPOCH)
741 .unwrap()
742 .as_nanos()
743 ))
744 }
745
746 #[test]
747 fn skill_invocation_rejects_parent_dir_references() {
748 let root = temp_dir("skill-ref-escape");
749 std::fs::create_dir_all(root.join("review").join("references")).unwrap();
750 std::fs::write(
751 root.join("review").join("SKILL.md"),
752 "# Skill: review\n\n**Trigger:** review\n\n**References:** ../secret.txt, references/checklist.md\n\n## Body\nReview carefully.",
753 )
754 .unwrap();
755 std::fs::write(
756 root.join("review").join("references").join("checklist.md"),
757 "ok",
758 )
759 .unwrap();
760 std::fs::write(root.join("secret.txt"), "nope").unwrap();
761
762 let lib = FsSkillLibrary::new(root.clone());
763 let invocation = lib.invoke("review").unwrap().expect("skill should exist");
764
765 assert_eq!(invocation.loaded_references.len(), 1);
766 assert_eq!(invocation.loaded_references[0].0, "references/checklist.md");
767
768 let _ = std::fs::remove_dir_all(root);
769 }
770
771 #[test]
772 fn curator_preserves_skill_assets_and_updates_skill_md_only() {
773 let root = temp_dir("curator-assets");
774 let skill_dir = root.join("refactor-safely");
775 std::fs::create_dir_all(skill_dir.join("references")).unwrap();
776 std::fs::write(skill_dir.join("references").join("checklist.md"), "keep me").unwrap();
777 std::fs::write(
778 skill_dir.join("SKILL.md"),
779 "# Skill: refactor-safely\n\n**Trigger:** refactor, rename\n\n**References:** references/checklist.md\n\n## Body\nMove in small steps.",
780 )
781 .unwrap();
782
783 Curator::new().curate(&root).unwrap();
784
785 assert!(
786 skill_dir.join("references").join("checklist.md").exists(),
787 "curator must not delete progressive-disclosure assets"
788 );
789 let lib = FsSkillLibrary::new(root.clone());
790 let invocation = lib
791 .invoke("refactor-safely")
792 .unwrap()
793 .expect("skill should remain");
794 assert_eq!(invocation.loaded_references[0].1, "keep me");
795
796 let _ = std::fs::remove_dir_all(root);
797 }
798
799 #[test]
800 fn skill_names_cannot_escape_skill_root() {
801 let root = temp_dir("skill-name-escape");
802 let lib = FsSkillLibrary::new(root.clone());
803 let skill = Skill {
804 name: "../outside".into(),
805 description: "bad".into(),
806 trigger: vec!["bad".into()],
807 body: "bad".into(),
808 source_file: String::new(),
809 usage_count: 0,
810 created_at: String::new(),
811 score: 0.5,
812 auto_generated: false,
813 references: Vec::new(),
814 templates: Vec::new(),
815 scripts: Vec::new(),
816 assets: Vec::new(),
817 };
818
819 assert!(lib.add(skill).is_err());
820 assert!(!root.join("..").join("outside").exists());
821
822 let _ = std::fs::remove_dir_all(root);
823 }
824}