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}
244
245pub struct FsSkillLibrary {
248 skills_dir: PathBuf,
249 memory: Option<Arc<dyn Memory>>,
250}
251
252impl FsSkillLibrary {
253 pub fn new(skills_dir: PathBuf) -> Self {
254 std::fs::create_dir_all(&skills_dir).ok();
255 Self {
256 skills_dir,
257 memory: None,
258 }
259 }
260
261 pub fn with_memory(mut self, memory: Arc<dyn Memory>) -> Self {
262 self.memory = Some(memory);
263 self
264 }
265
266 fn scan(&self) -> Vec<Skill> {
268 let mut skills = Vec::new();
269 if let Ok(entries) = std::fs::read_dir(&self.skills_dir) {
270 for entry in entries.flatten() {
271 let path = entry.path();
272 if path.is_dir() {
273 let skill_file = path.join("SKILL.md");
275 if skill_file.exists() {
276 if let Ok(content) = std::fs::read_to_string(&skill_file) {
277 let rel = path
278 .file_name()
279 .map(|n| n.to_string_lossy().to_string())
280 .unwrap_or_default();
281 if let Some(skill) = Skill::from_markdown(&content, &rel) {
282 skills.push(skill);
283 }
284 }
285 }
286 } else if path
287 .file_name()
288 .map(|n| n.to_string_lossy().to_lowercase().ends_with(".skill.md"))
289 .unwrap_or(false)
290 {
291 if let Ok(content) = std::fs::read_to_string(&path) {
292 let rel = path
293 .file_name()
294 .map(|n| n.to_string_lossy().to_string())
295 .unwrap_or_default();
296 if let Some(skill) = Skill::from_markdown(&content, &rel) {
297 skills.push(skill);
298 }
299 }
300 }
301 }
302 }
303 skills
304 }
305}
306
307impl SkillLibrary for FsSkillLibrary {
308 fn relevant(&self, ctx: &str, limit: usize) -> Vec<Skill> {
309 let mut scored: Vec<(f64, Skill)> = self
310 .scan()
311 .into_iter()
312 .map(|s| {
313 let r = s.relevance(ctx);
314 (r, s)
315 })
316 .filter(|(r, _)| *r > 0.0)
317 .collect();
318
319 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
320
321 scored.into_iter().take(limit).map(|(_, s)| s).collect()
322 }
323
324 fn add(&self, skill: Skill) -> anyhow::Result<()> {
325 let skill_dir = self.skills_dir.join(safe_skill_dir_name(&skill.name)?);
327 std::fs::create_dir_all(&skill_dir)?;
328
329 let skill_file = skill_dir.join("SKILL.md");
330 let content = skill.to_markdown();
331 std::fs::write(&skill_file, content)?;
332
333 if let Some(mem) = &self.memory {
335 let _ = mem.upsert_doc(crate::memory::WorkingDoc {
336 id: format!("skill-{}", skill.name),
337 title: format!("Skill: {}", skill.name),
338 content: skill.body.clone(),
339 updated_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
340 });
341 }
342
343 Ok(())
344 }
345
346 fn all(&self) -> Vec<Skill> {
347 self.scan()
348 }
349
350 fn curate(&self) -> anyhow::Result<()> {
351 let curator = Curator::new();
352 curator.curate(&self.skills_dir)
353 }
354
355 fn prune(&self, min_score: f64) -> anyhow::Result<usize> {
356 let skills = self.scan();
357 let mut removed = 0;
358
359 for skill in &skills {
360 if skill.score < min_score && skill.auto_generated {
361 let skill_dir = self.skills_dir.join(safe_skill_dir_name(&skill.name)?);
362 if skill_dir.exists() {
363 std::fs::remove_dir_all(&skill_dir)?;
364 removed += 1;
365 }
366 }
367 }
368
369 Ok(removed)
370 }
371
372 fn get(&self, name: &str) -> Option<Skill> {
373 self.scan().into_iter().find(|s| s.name == name)
374 }
375
376 fn invoke(&self, name: &str) -> anyhow::Result<Option<SkillInvocation>> {
377 let Some(skill) = self.get(name) else {
378 return Ok(None);
379 };
380 let base = if skill.source_file.ends_with(".skill.md") {
381 self.skills_dir.clone()
382 } else {
383 self.skills_dir.join(&skill.source_file)
384 };
385 let load_files = |files: &[String]| -> Vec<(String, String)> {
386 let mut loaded = Vec::new();
387 for f in files {
388 let Ok(candidate) = safe_relative_path(&base, f) else {
389 continue;
390 };
391 if let Ok(content) = std::fs::read_to_string(&candidate) {
392 loaded.push((f.clone(), content));
393 }
394 }
395 loaded
396 };
397 Ok(Some(SkillInvocation {
398 loaded_references: load_files(&skill.references),
399 loaded_templates: load_files(&skill.templates),
400 loaded_scripts: load_files(&skill.scripts),
401 loaded_assets: load_files(&skill.assets),
402 skill,
403 }))
404 }
405
406 fn remove(&self, name: &str) -> anyhow::Result<bool> {
407 let skill_dir = self.skills_dir.join(safe_skill_dir_name(name)?);
409 let existed = skill_dir.exists();
410 if existed {
411 std::fs::remove_dir_all(&skill_dir)?;
412 }
413 Ok(existed)
414 }
415}
416
417fn safe_skill_dir_name(name: &str) -> anyhow::Result<String> {
418 let trimmed = name.trim();
419 if trimmed.is_empty()
420 || trimmed.contains("..")
421 || trimmed.contains('/')
422 || trimmed.contains('\\')
423 || trimmed.contains(':')
424 {
425 anyhow::bail!("invalid skill name '{}'", name);
426 }
427 Ok(trimmed.to_string())
428}
429
430fn safe_relative_path(base: &Path, relative: &str) -> anyhow::Result<PathBuf> {
431 let rel = Path::new(relative);
432 if rel.is_absolute()
433 || rel
434 .components()
435 .any(|c| matches!(c, std::path::Component::ParentDir))
436 {
437 anyhow::bail!("skill reference escapes skill directory: {}", relative);
438 }
439 let candidate = base.join(rel);
440 let canonical_base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
441 let canonical_candidate = candidate
442 .canonicalize()
443 .unwrap_or_else(|_| candidate.to_path_buf());
444 if !canonical_candidate.starts_with(&canonical_base) || !canonical_candidate.exists() {
445 anyhow::bail!("skill reference outside base or missing: {}", relative);
446 }
447 Ok(canonical_candidate)
448}
449
450pub struct Curator {
453 min_score: f64,
454 max_skills: usize,
455}
456
457impl Curator {
458 pub fn new() -> Self {
459 Self {
460 min_score: 0.2,
461 max_skills: 100,
462 }
463 }
464
465 pub fn curate(&self, skills_dir: &Path) -> anyhow::Result<()> {
469 let library = FsSkillLibrary::new(skills_dir.to_path_buf());
470 let mut skills = library.all();
471
472 if skills.is_empty() {
473 return Ok(());
474 }
475
476 for skill in &mut skills {
478 skill.score += skill.usage_count as f64 * 0.05;
480 skill.score += (skill.body.len() as f64 / 5000.0).min(0.1);
482 skill.score = skill.score.min(1.0);
484 }
485
486 let mut merged = Vec::new();
488 let mut merged_indices = std::collections::HashSet::new();
489
490 for i in 0..skills.len() {
491 if merged_indices.contains(&i) {
492 continue;
493 }
494 let mut current = skills[i].clone();
495
496 for j in (i + 1)..skills.len() {
497 if merged_indices.contains(&j) {
498 continue;
499 }
500 let name_overlap = current.name
502 [..current.name.len().min(3).min(skills[j].name.len())]
503 == skills[j].name[..skills[j].name.len().min(3).min(current.name.len())];
504
505 let trigger_overlap = {
506 let a: std::collections::HashSet<_> = current.trigger.iter().cloned().collect();
507 let b: std::collections::HashSet<_> =
508 skills[j].trigger.iter().cloned().collect();
509 let intersection = a.intersection(&b).count();
510 let union = a.union(&b).count();
511 if union == 0 {
512 false
513 } else {
514 intersection as f64 / union as f64 > 0.5
515 }
516 };
517
518 if name_overlap || trigger_overlap {
519 current.body = format!("{}\n\n---\n\n{}", current.body, skills[j].body);
521 current.score = current.score.max(skills[j].score);
522 current.trigger.extend(skills[j].trigger.clone());
523 current.trigger.sort();
524 current.trigger.dedup();
525 merged_indices.insert(j);
526 }
527 }
528 merged.push(current);
529 }
530
531 merged.retain(|s| !s.auto_generated || s.score >= self.min_score);
533 merged.sort_by(|a, b| {
534 b.score
535 .partial_cmp(&a.score)
536 .unwrap_or(std::cmp::Ordering::Equal)
537 });
538 if merged.len() > self.max_skills {
539 merged.truncate(self.max_skills);
540 }
541
542 for skill in &merged {
545 let skill_dir = skills_dir.join(safe_skill_dir_name(&skill.name)?);
546 std::fs::create_dir_all(&skill_dir)?;
547 std::fs::write(skill_dir.join("SKILL.md"), skill.to_markdown())?;
548 }
549
550 tracing::info!(
551 "Curator: {} skills before → {} after (deduped {}, pruned {})",
552 skills.len(),
553 merged.len(),
554 skills.len() - merged_indices.len(),
555 skills.len() + merged_indices.len() - merged.len() - skills.len().min(merged.len()),
556 );
557
558 Ok(())
559 }
560
561 pub fn propose_skill(run_description: &str, outcome: &str) -> Option<Skill> {
564 let words: Vec<&str> = run_description.split_whitespace().collect();
565 let lower = run_description.to_lowercase();
566 let outcome_lower = outcome.to_lowercase();
567
568 if words.len() < 5 || outcome_lower.contains("error") {
569 return None;
570 }
571
572 let specificity_markers = [
573 "github.com",
574 "http",
575 "https",
576 "this ",
577 "that ",
578 "the file",
579 "my ",
580 "your ",
581 "2024",
582 "2025",
583 "2026",
584 ];
585 if specificity_markers
586 .iter()
587 .any(|marker| lower.contains(marker))
588 {
589 return None;
590 }
591 if words.iter().any(|word| {
592 let cleaned = word.trim_matches(|c: char| !c.is_alphanumeric());
593 cleaned
596 .chars()
597 .next()
598 .map(|c| c.is_uppercase())
599 .unwrap_or(false)
600 && cleaned.chars().count() > 12
601 }) {
602 return None;
603 }
604
605 let has_concrete_output = [
606 "diff", "fn ", "struct ", "impl ", "test", "fixed", "refactor", "added", "updated",
607 "created", "modified", "patch", "write", "edit", "return", "async", "pub ", "let ",
608 "const ", "mod ",
609 ]
610 .iter()
611 .any(|needle| outcome_lower.contains(needle));
612 if !has_concrete_output {
613 return None;
614 }
615
616 let name = skill_name_from_pattern(run_description)?.to_string();
617 let triggers = skill_triggers_for_pattern(&name);
618
619 Some(Skill {
620 name,
621 description: format!("Reusable pattern learned from: {}", run_description),
622 trigger: triggers,
623 body: format!(
624 "## Context\nTask: {}\n\n## Approach\n{}",
625 run_description, outcome
626 ),
627 source_file: String::new(),
628 usage_count: 0,
629 created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
630 score: 0.3,
631 auto_generated: true,
632 references: Vec::new(),
633 templates: Vec::new(),
634 scripts: Vec::new(),
635 assets: Vec::new(),
636 })
637 }
638
639 pub fn propose_skill_if_missing(
640 run_description: &str,
641 outcome: &str,
642 library: &dyn SkillLibrary,
643 ) -> Option<Skill> {
644 let candidate = Self::propose_skill(run_description, outcome)?;
645 if library.get(&candidate.name).is_some() {
646 None
647 } else {
648 Some(candidate)
649 }
650 }
651}
652
653pub fn skill_name_from_pattern(description: &str) -> Option<&'static str> {
654 let d = description.to_lowercase();
655 if d.contains("test") && (d.contains("add") || d.contains("write") || d.contains("fix")) {
656 return Some("write-and-fix-tests");
657 }
658 if d.contains("refactor") || d.contains("rename") || d.contains("extract") {
659 return Some("refactor-safely");
660 }
661 if d.contains("debug") || d.contains("error") || d.contains("panic") || d.contains("crash") {
662 return Some("debug-systematically");
663 }
664 if d.contains("document")
665 || d.contains("comment")
666 || d.contains("readme")
667 || d.contains("docstring")
668 {
669 return Some("write-docs");
670 }
671 if d.contains("secur") || d.contains("vulnerab") || d.contains("audit") {
672 return Some("security-audit");
673 }
674 if d.contains("performance") || d.contains("slow") || d.contains("optim") || d.contains("bench")
675 {
676 return Some("performance-profile");
677 }
678 if d.contains("upgrade") || d.contains("bump") || d.contains("depend") || d.contains("package")
679 {
680 return Some("upgrade-dependencies");
681 }
682 if d.contains("review") || d.contains("pr") || d.contains("pull request") || d.contains("diff")
683 {
684 return Some("code-review");
685 }
686 if d.contains("git") || d.contains("commit") || d.contains("branch") || d.contains("merge") {
687 return Some("git-workflow");
688 }
689 None
690}
691
692fn skill_triggers_for_pattern(name: &str) -> Vec<String> {
693 match name {
694 "write-and-fix-tests" => vec!["test", "unit", "fix", "assert"],
695 "refactor-safely" => vec!["refactor", "rename", "extract", "safe"],
696 "debug-systematically" => vec!["debug", "error", "panic", "crash"],
697 "write-docs" => vec!["document", "readme", "comment", "docstring"],
698 "security-audit" => vec!["security", "audit", "vulnerability", "safe"],
699 "performance-profile" => vec!["performance", "slow", "optimize", "bench"],
700 "upgrade-dependencies" => vec!["upgrade", "bump", "dependency", "package"],
701 "code-review" => vec!["review", "pr", "diff", "pull-request"],
702 "git-workflow" => vec!["git", "commit", "branch", "merge"],
703 _ => vec!["skill"],
704 }
705 .into_iter()
706 .map(String::from)
707 .collect()
708}
709
710impl Default for Curator {
711 fn default() -> Self {
712 Self::new()
713 }
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719
720 fn temp_dir(name: &str) -> PathBuf {
721 std::env::temp_dir().join(format!(
722 "sparrow-tier2-{name}-{}",
723 std::time::SystemTime::now()
724 .duration_since(std::time::UNIX_EPOCH)
725 .unwrap()
726 .as_nanos()
727 ))
728 }
729
730 #[test]
731 fn skill_invocation_rejects_parent_dir_references() {
732 let root = temp_dir("skill-ref-escape");
733 std::fs::create_dir_all(root.join("review").join("references")).unwrap();
734 std::fs::write(
735 root.join("review").join("SKILL.md"),
736 "# Skill: review\n\n**Trigger:** review\n\n**References:** ../secret.txt, references/checklist.md\n\n## Body\nReview carefully.",
737 )
738 .unwrap();
739 std::fs::write(
740 root.join("review").join("references").join("checklist.md"),
741 "ok",
742 )
743 .unwrap();
744 std::fs::write(root.join("secret.txt"), "nope").unwrap();
745
746 let lib = FsSkillLibrary::new(root.clone());
747 let invocation = lib.invoke("review").unwrap().expect("skill should exist");
748
749 assert_eq!(invocation.loaded_references.len(), 1);
750 assert_eq!(invocation.loaded_references[0].0, "references/checklist.md");
751
752 let _ = std::fs::remove_dir_all(root);
753 }
754
755 #[test]
756 fn curator_preserves_skill_assets_and_updates_skill_md_only() {
757 let root = temp_dir("curator-assets");
758 let skill_dir = root.join("refactor-safely");
759 std::fs::create_dir_all(skill_dir.join("references")).unwrap();
760 std::fs::write(skill_dir.join("references").join("checklist.md"), "keep me").unwrap();
761 std::fs::write(
762 skill_dir.join("SKILL.md"),
763 "# Skill: refactor-safely\n\n**Trigger:** refactor, rename\n\n**References:** references/checklist.md\n\n## Body\nMove in small steps.",
764 )
765 .unwrap();
766
767 Curator::new().curate(&root).unwrap();
768
769 assert!(
770 skill_dir.join("references").join("checklist.md").exists(),
771 "curator must not delete progressive-disclosure assets"
772 );
773 let lib = FsSkillLibrary::new(root.clone());
774 let invocation = lib
775 .invoke("refactor-safely")
776 .unwrap()
777 .expect("skill should remain");
778 assert_eq!(invocation.loaded_references[0].1, "keep me");
779
780 let _ = std::fs::remove_dir_all(root);
781 }
782
783 #[test]
784 fn skill_names_cannot_escape_skill_root() {
785 let root = temp_dir("skill-name-escape");
786 let lib = FsSkillLibrary::new(root.clone());
787 let skill = Skill {
788 name: "../outside".into(),
789 description: "bad".into(),
790 trigger: vec!["bad".into()],
791 body: "bad".into(),
792 source_file: String::new(),
793 usage_count: 0,
794 created_at: String::new(),
795 score: 0.5,
796 auto_generated: false,
797 references: Vec::new(),
798 templates: Vec::new(),
799 scripts: Vec::new(),
800 assets: Vec::new(),
801 };
802
803 assert!(lib.add(skill).is_err());
804 assert!(!root.join("..").join("outside").exists());
805
806 let _ = std::fs::remove_dir_all(root);
807 }
808}