Skip to main content

synaptic_deep/middleware/
skills.rs

1use async_trait::async_trait;
2use std::sync::Arc;
3use synaptic_core::SynapticError;
4use synaptic_middleware::{AgentMiddleware, ModelRequest};
5
6use crate::backend::Backend;
7
8/// A discovered skill with metadata parsed from YAML frontmatter.
9pub struct Skill {
10    pub name: String,
11    pub description: String,
12    pub path: String,
13}
14
15/// Middleware that discovers skills from the backend and injects an index into the system prompt.
16///
17/// Scans `{skills_dir}/*/SKILL.md` for YAML frontmatter containing `name` and `description`.
18/// The agent can read the full SKILL.md via the `read_file` tool for detailed instructions.
19pub struct SkillsMiddleware {
20    backend: Arc<dyn Backend>,
21    skills_dir: String,
22}
23
24impl SkillsMiddleware {
25    pub fn new(backend: Arc<dyn Backend>, skills_dir: String) -> Self {
26        Self {
27            backend,
28            skills_dir,
29        }
30    }
31
32    async fn discover_skills(&self) -> Vec<Skill> {
33        let entries = match self.backend.ls(&self.skills_dir).await {
34            Ok(e) => e,
35            Err(_) => return Vec::new(),
36        };
37
38        let mut skills = Vec::new();
39        for entry in entries {
40            if !entry.is_dir {
41                continue;
42            }
43            let skill_path = format!("{}/{}/SKILL.md", self.skills_dir, entry.name);
44            if let Ok(content) = self.backend.read_file(&skill_path, 0, 50).await {
45                if let Some(skill) = parse_skill_frontmatter(&content, &skill_path) {
46                    skills.push(skill);
47                }
48            }
49        }
50        skills
51    }
52}
53
54/// Parse YAML frontmatter between `---` markers to extract `name` and `description`.
55fn parse_skill_frontmatter(content: &str, path: &str) -> Option<Skill> {
56    let mut lines = content.lines();
57
58    if lines.next()?.trim() != "---" {
59        return None;
60    }
61
62    let mut name = None;
63    let mut description = None;
64
65    for line in lines {
66        let trimmed = line.trim();
67        if trimmed == "---" {
68            break;
69        }
70        if let Some(val) = trimmed.strip_prefix("name:") {
71            name = Some(val.trim().trim_matches('"').trim_matches('\'').to_string());
72        } else if let Some(val) = trimmed.strip_prefix("description:") {
73            description = Some(val.trim().trim_matches('"').trim_matches('\'').to_string());
74        }
75    }
76
77    Some(Skill {
78        name: name?,
79        description: description.unwrap_or_default(),
80        path: path.to_string(),
81    })
82}
83
84#[async_trait]
85impl AgentMiddleware for SkillsMiddleware {
86    async fn before_model(&self, request: &mut ModelRequest) -> Result<(), SynapticError> {
87        let skills = self.discover_skills().await;
88        if skills.is_empty() {
89            return Ok(());
90        }
91
92        let mut section = String::from("\n<available_skills>\n");
93        for skill in &skills {
94            section.push_str(&format!(
95                "- **{}**: {} (read `{}` for details)\n",
96                skill.name, skill.description, skill.path
97            ));
98        }
99        section.push_str("</available_skills>\n");
100
101        if let Some(ref mut prompt) = request.system_prompt {
102            prompt.push_str(&section);
103        } else {
104            request.system_prompt = Some(section);
105        }
106        Ok(())
107    }
108}