Skip to main content

apollo/skills/
mod.rs

1//! Skills system — scan SKILL.md files, match against user requests,
2//! inject matched skill instructions into the system prompt.
3//! Supports template variables and inline shell execution.
4
5pub mod curator;
6
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone)]
12pub struct Skill {
13    pub name: String,
14    pub description: String,
15    pub location: PathBuf,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ManagedSkillInput {
20    pub name: String,
21    pub description: String,
22    pub body: String,
23}
24
25/// Scan known skill directories for SKILL.md files
26pub fn discover_skills() -> Vec<Skill> {
27    discover_skills_for_workspace(None)
28}
29
30pub fn discover_skills_for_workspace(workspace: Option<&Path>) -> Vec<Skill> {
31    let mut skills = Vec::new();
32    let home = dirs::home_dir().unwrap_or_default();
33
34    let openclaw_skills = home.join(".npm-global/lib/node_modules/openclaw/skills");
35    scan_skill_dir(&openclaw_skills, &mut skills);
36
37    let workspace_skills = home.join(".openclaw/workspace/skills");
38    scan_skill_dir(&workspace_skills, &mut skills);
39
40    if let Some(workspace) = workspace {
41        let managed = managed_skills_dir(workspace);
42        scan_skill_dir(&managed, &mut skills);
43
44        // Host plugin directories carry SKILL.md too. `discover_host_plugins`
45        // scanned these and only logged what it found, so an OpenClaw plugin
46        // dropped into `plugins/` was discovered and then ignored by the one
47        // system that could have used it.
48        for dir in host_plugin_skill_dirs(workspace) {
49            scan_skill_dir(&dir, &mut skills);
50        }
51    }
52
53    skills
54}
55
56/// Plugin roots that may contain SKILL.md-bearing directories. Same roots
57/// `plugin_hosts::discover_host_plugins` scans, deliberately.
58fn host_plugin_skill_dirs(workspace: &Path) -> Vec<PathBuf> {
59    vec![
60        workspace.join("plugins"),
61        workspace.join(".openclaw/plugins"),
62        workspace.join(".hermes/plugins"),
63    ]
64}
65
66pub fn managed_skills_dir(workspace: &Path) -> PathBuf {
67    workspace.join(".apollo/skills")
68}
69
70fn scan_skill_dir(dir: &Path, skills: &mut Vec<Skill>) {
71    if !dir.is_dir() {
72        return;
73    }
74
75    if let Ok(entries) = std::fs::read_dir(dir) {
76        for entry in entries.flatten() {
77            let skill_dir = entry.path();
78            if !skill_dir.is_dir() {
79                continue;
80            }
81
82            let skill_md = skill_dir.join("SKILL.md");
83            if !skill_md.exists() {
84                continue;
85            }
86
87            if let Ok(content) = std::fs::read_to_string(&skill_md) {
88                if let Some(skill) = parse_skill_frontmatter(&content, &skill_md) {
89                    skills.push(skill);
90                }
91            }
92        }
93    }
94}
95
96fn extract_frontmatter(content: &str) -> Option<&str> {
97    let rest = content.strip_prefix("---")?;
98    let end = rest.find("---")?;
99    Some(&rest[..end])
100}
101
102fn default_skill_name(path: &Path) -> String {
103    path.parent()
104        .and_then(|p| p.file_name())
105        .map(|n| n.to_string_lossy().to_string())
106        .unwrap_or_else(|| "unknown".to_string())
107}
108
109/// Parse YAML-like frontmatter from SKILL.md
110fn parse_skill_frontmatter(content: &str, path: &Path) -> Option<Skill> {
111    let frontmatter = extract_frontmatter(content)?;
112
113    let mut name = None;
114    let mut description = None;
115
116    for line in frontmatter.lines() {
117        let trimmed = line.trim();
118        if let Some(val) = trimmed.strip_prefix("name:") {
119            name = Some(val.trim().trim_matches('\'').trim_matches('"').to_string());
120        }
121        if let Some(val) = trimmed.strip_prefix("description:") {
122            description = Some(val.trim().trim_matches('\'').trim_matches('"').to_string());
123        }
124    }
125
126    Some(Skill {
127        name: name.unwrap_or_else(|| default_skill_name(path)),
128        description: description.unwrap_or_default(),
129        location: path.to_path_buf(),
130    })
131}
132
133// ── Matching ──────────────────────────────────────────────────────────────
134
135const STOPWORDS: &[&str] = &[
136    "the", "and", "for", "are", "but", "not", "you", "all", "can", "had", "her", "was", "one",
137    "our", "out", "has", "have", "been", "some", "them", "than", "its", "over", "such", "that",
138    "this", "with", "will", "each", "from", "they", "were", "which", "their", "said", "what",
139    "when", "who", "how", "use", "new", "now", "way", "may", "get", "got", "set", "let", "any",
140    "also", "into", "just", "only", "very", "even", "most", "other", "need", "make", "like",
141    "does", "your", "more", "want", "should",
142];
143
144fn is_stopword(word: &str) -> bool {
145    STOPWORDS.contains(&word)
146}
147
148pub fn match_skill<'a>(skills: &'a [Skill], user_message: &str) -> Option<&'a Skill> {
149    let msg_lower = user_message.to_lowercase();
150    let msg_words: Vec<&str> = msg_lower.split_whitespace().collect();
151
152    let mut best_score = 0.0f32;
153    let mut best_skill = None;
154
155    for skill in skills {
156        let desc_lower = skill.description.to_lowercase();
157        let name_lower = skill.name.to_lowercase();
158        let mut score = 0.0f32;
159
160        if msg_lower.contains(&name_lower) && name_lower.len() >= 4 {
161            score += 10.0;
162        }
163
164        for word in &msg_words {
165            if word.len() < 4 || is_stopword(word) {
166                continue;
167            }
168            if desc_lower.contains(word) {
169                score += 1.0;
170            }
171        }
172
173        for word in desc_lower.split(|c: char| !c.is_alphanumeric()) {
174            if word.len() < 4 || is_stopword(word) {
175                continue;
176            }
177            if msg_lower.contains(word) {
178                score += 1.0;
179            }
180        }
181
182        if score > best_score {
183            best_score = score;
184            best_skill = Some(skill);
185        }
186    }
187
188    if best_score >= 5.0 {
189        best_skill
190    } else {
191        None
192    }
193}
194
195// ── Template variable substitution / inline shell ─────────────────
196// Ported from hermes-agent skill_preprocessing.py
197
198use regex::Regex;
199use std::sync::OnceLock;
200
201fn skill_template_re() -> &'static Regex {
202    static RE: OnceLock<Regex> = OnceLock::new();
203    RE.get_or_init(|| Regex::new(r"\$\{(HERMES_SKILL_DIR|HERMES_SESSION_ID)\}").unwrap())
204}
205
206fn inline_shell_re() -> &'static Regex {
207    static RE: OnceLock<Regex> = OnceLock::new();
208    RE.get_or_init(|| Regex::new(r"!`([^`\n]+)`").unwrap())
209}
210
211/// Load skill content raw (no preprocessing).
212pub fn load_skill_content(skill: &Skill) -> Option<String> {
213    std::fs::read_to_string(&skill.location).ok()
214}
215
216/// Preprocess skill content: substitute template vars and expand inline shell.
217pub fn preprocess_skill_content(
218    content: &str,
219    skill_dir: Option<&Path>,
220    session_id: Option<&str>,
221    cwd: Option<&Path>,
222) -> String {
223    let content = substitute_template_vars(content, skill_dir, session_id);
224    expand_inline_shell(&content, cwd, 30)
225}
226
227/// Substitute `${HERMES_SKILL_DIR}` and `${HERMES_SESSION_ID}` in skill content.
228/// Unresolved tokens are left as-is so the author can debug them.
229pub fn substitute_template_vars(
230    content: &str,
231    skill_dir: Option<&Path>,
232    session_id: Option<&str>,
233) -> String {
234    skill_template_re()
235        .replace_all(content, |caps: &regex::Captures| {
236            match caps.get(1).map(|m| m.as_str()) {
237                Some("HERMES_SKILL_DIR") => skill_dir
238                    .map(|p| p.to_string_lossy().to_string())
239                    .unwrap_or_else(|| caps[0].to_string()),
240                Some("HERMES_SESSION_ID") => session_id
241                    .map(|s| s.to_string())
242                    .unwrap_or_else(|| caps[0].to_string()),
243                _ => caps[0].to_string(),
244            }
245        })
246        .to_string()
247}
248
249/// Execute inline shell snippets (`!\`command\``) in skill content.
250/// Replaces each snippet with its stdout (trimmed).
251/// Failures produce a short `[inline-shell error: ...]` marker.
252pub fn expand_inline_shell(content: &str, cwd: Option<&Path>, _timeout_secs: u64) -> String {
253    inline_shell_re()
254        .replace_all(content, |caps: &regex::Captures| {
255            let cmd = caps.get(1).map(|m| m.as_str()).unwrap_or("");
256            if cmd.is_empty() {
257                return String::new();
258            }
259            match std::process::Command::new("sh")
260                .arg("-c")
261                .arg(cmd)
262                .current_dir(cwd.unwrap_or(Path::new(".")))
263                .output()
264            {
265                Ok(output) if output.status.success() => {
266                    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
267                    match crate::text::truncate_chars_counted(&stdout, 4000) {
268                        Some((head, dropped)) => {
269                            format!("{}… [truncated {} chars]", head, dropped)
270                        }
271                        None => stdout,
272                    }
273                }
274                Ok(output) => {
275                    let stderr = String::from_utf8_lossy(&output.stderr);
276                    format!(
277                        "[inline-shell error: {}]",
278                        stderr.trim().chars().take(120).collect::<String>()
279                    )
280                }
281                Err(e) => format!("[inline-shell error: {}]", e),
282            }
283        })
284        .to_string()
285}
286
287// ── Save managed skill ────────────────────────────────────────────────────
288
289pub fn save_managed_skill(workspace: &Path, input: &ManagedSkillInput) -> anyhow::Result<PathBuf> {
290    let dir = managed_skills_dir(workspace).join(slugify(&input.name));
291    std::fs::create_dir_all(&dir)?;
292    let path = dir.join("SKILL.md");
293    let content = format!(
294        "---\nname: {}\ndescription: {}\n---\n\n{}\n",
295        input.name, input.description, input.body
296    );
297    std::fs::write(&path, content)?;
298    Ok(path)
299}
300
301fn slugify(name: &str) -> String {
302    name.to_lowercase()
303        .chars()
304        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
305        .collect::<String>()
306        .trim_matches('-')
307        .to_string()
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn host_plugin_skills_are_discovered() {
316        let dir = tempfile::tempdir().unwrap();
317        let workspace = dir.path();
318
319        // An OpenClaw-style plugin dropped into the workspace. Discovery used
320        // to find this and log it; nothing loaded it.
321        let plugin = workspace.join("plugins/weather");
322        std::fs::create_dir_all(&plugin).unwrap();
323        std::fs::write(
324            plugin.join("SKILL.md"),
325            "---\nname: weather\ndescription: forecast lookup\n---\n# Weather\n",
326        )
327        .unwrap();
328
329        let skills = discover_skills_for_workspace(Some(workspace));
330        assert!(
331            skills.iter().any(|s| s.name == "weather"),
332            "host plugin SKILL.md was discovered but never loaded; got {:?}",
333            skills.iter().map(|s| &s.name).collect::<Vec<_>>()
334        );
335    }
336
337    #[test]
338    fn hermes_and_openclaw_plugin_roots_are_both_scanned() {
339        let dir = tempfile::tempdir().unwrap();
340        let workspace = dir.path();
341
342        for (root, name) in [
343            (".hermes/plugins", "hermes-skill"),
344            (".openclaw/plugins", "openclaw-skill"),
345        ] {
346            let plugin = workspace.join(root).join(name);
347            std::fs::create_dir_all(&plugin).unwrap();
348            std::fs::write(
349                plugin.join("SKILL.md"),
350                format!("---\nname: {name}\ndescription: d\n---\n"),
351            )
352            .unwrap();
353        }
354
355        let found: Vec<String> = discover_skills_for_workspace(Some(workspace))
356            .into_iter()
357            .map(|s| s.name)
358            .collect();
359        assert!(found.contains(&"hermes-skill".to_string()), "{found:?}");
360        assert!(found.contains(&"openclaw-skill".to_string()), "{found:?}");
361    }
362
363    #[test]
364    fn test_parse_frontmatter() {
365        let content = "---\nname: test-skill\ndescription: A test skill\n---\n# Content";
366        let skill = parse_skill_frontmatter(content, Path::new("/tmp/test/SKILL.md"));
367        assert!(skill.is_some());
368        let s = skill.unwrap();
369        assert_eq!(s.name, "test-skill");
370        assert_eq!(s.description, "A test skill");
371    }
372
373    #[test]
374    fn test_match_skill() {
375        let skills = vec![
376            Skill {
377                name: "weather".to_string(),
378                description: "Get weather forecasts for any location".to_string(),
379                location: PathBuf::from("/tmp/weather/SKILL.md"),
380            },
381            Skill {
382                name: "github".to_string(),
383                description: "GitHub operations, PRs, issues, code review".to_string(),
384                location: PathBuf::from("/tmp/github/SKILL.md"),
385            },
386        ];
387
388        let matched = match_skill(&skills, "what's the weather in Melbourne?");
389        assert!(matched.is_some());
390        assert_eq!(matched.unwrap().name, "weather");
391
392        let matched = match_skill(&skills, "review the github issues");
393        assert!(matched.is_some());
394        assert_eq!(matched.unwrap().name, "github");
395    }
396
397    #[test]
398    fn test_template_substitution() {
399        let result = substitute_template_vars(
400            "Run from ${HERMES_SKILL_DIR} for session ${HERMES_SESSION_ID}",
401            Some(Path::new("/tmp/myskill")),
402            Some("sess_123"),
403        );
404        assert_eq!(result, "Run from /tmp/myskill for session sess_123");
405    }
406
407    #[test]
408    fn test_unknown_template_preserved() {
409        let content = "Token ${UNKNOWN} stays";
410        let result = substitute_template_vars(content, None, None);
411        assert_eq!(result, "Token ${UNKNOWN} stays");
412    }
413
414    #[test]
415    fn test_inline_shell_expansion() {
416        let result = expand_inline_shell("Date is !`echo hello`", None, 5);
417        assert!(result.contains("hello"));
418    }
419}