Skip to main content

walrus_skill/
lib.rs

1//! Walrus skill registry — tag-indexed skill matching and prompt enrichment.
2//!
3//! Skills are named units of agent behavior loaded from Markdown files with
4//! YAML frontmatter. The [`SkillRegistry`] indexes skills by tags and triggers,
5//! and implements [`Hook`] to enrich agent system prompts based on skill tags.
6
7use anyhow::Result;
8use compact_str::CompactString;
9use std::{collections::BTreeMap, path::PathBuf};
10use tokio::sync::RwLock;
11use wcore::Hook;
12
13pub mod loader;
14
15// ── Skill data types ───────────────────────────────────────────────────
16
17/// Priority tier for skill resolution.
18///
19/// Variant order defines precedence: Workspace overrides Managed, which
20/// overrides Bundled. Assigned by the registry at load time based on
21/// source directory — not stored in the skill file.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum SkillTier {
24    /// Ships with the binary.
25    Bundled,
26    /// Installed via package manager.
27    Managed,
28    /// Defined in the project workspace.
29    Workspace,
30}
31
32/// A named unit of agent behavior (agentskills.io format).
33///
34/// Pure data struct — parsing logic lives in the [`loader`] module.
35/// Fields mirror the agentskills.io specification. Runtime-only concepts
36/// like tier and priority live in the registry, not here.
37#[derive(Debug, Clone)]
38pub struct Skill {
39    /// Skill identifier (lowercase, hyphens, 1-64 chars).
40    pub name: CompactString,
41    /// Human-readable description (1-1024 chars).
42    pub description: String,
43    /// SPDX license identifier.
44    pub license: Option<CompactString>,
45    /// Compatibility constraints (e.g. "walrus>=0.1").
46    pub compatibility: Option<CompactString>,
47    /// Arbitrary key-value metadata map.
48    pub metadata: BTreeMap<CompactString, String>,
49    /// Tool names this skill is allowed to use.
50    pub allowed_tools: Vec<CompactString>,
51    /// Skill body (Markdown instructions).
52    pub body: String,
53}
54
55/// An indexed skill with its tier and priority (extracted from metadata).
56#[derive(Debug, Clone)]
57struct IndexedSkill {
58    skill: Skill,
59    tier: SkillTier,
60    priority: u8,
61}
62
63// ── Skill registry ─────────────────────────────────────────────────────
64
65/// A registry of loaded skills with tag and trigger indices.
66#[derive(Debug, Clone)]
67pub struct SkillRegistry {
68    skills: Vec<IndexedSkill>,
69    tag_index: BTreeMap<CompactString, Vec<usize>>,
70    trigger_index: BTreeMap<CompactString, Vec<usize>>,
71}
72
73impl Default for SkillRegistry {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl SkillRegistry {
80    /// Create an empty registry.
81    pub fn new() -> Self {
82        Self {
83            skills: Vec::new(),
84            tag_index: BTreeMap::new(),
85            trigger_index: BTreeMap::new(),
86        }
87    }
88
89    /// Add a skill to the registry with the given tier.
90    pub fn add(&mut self, skill: Skill, tier: SkillTier) {
91        let priority = skill
92            .metadata
93            .get("priority")
94            .and_then(|v| v.parse::<u8>().ok())
95            .unwrap_or(0);
96
97        let idx = self.skills.len();
98
99        // Index tags from metadata["tags"] (comma-separated).
100        if let Some(tags) = skill.metadata.get("tags") {
101            for tag in tags.split(',') {
102                let tag = tag.trim();
103                if !tag.is_empty() {
104                    self.tag_index
105                        .entry(CompactString::from(tag))
106                        .or_default()
107                        .push(idx);
108                }
109            }
110        }
111
112        // Index triggers from metadata["triggers"] (comma-separated).
113        if let Some(triggers) = skill.metadata.get("triggers") {
114            for trigger in triggers.split(',') {
115                let trigger = trigger.trim().to_lowercase();
116                if !trigger.is_empty() {
117                    self.trigger_index
118                        .entry(CompactString::from(trigger))
119                        .or_default()
120                        .push(idx);
121                }
122            }
123        }
124
125        self.skills.push(IndexedSkill {
126            skill,
127            tier,
128            priority,
129        });
130    }
131
132    /// Find skills matching any of the given tags, sorted by tier (desc) then priority (desc).
133    pub fn find_by_tags(&self, tags: &[CompactString]) -> Vec<&Skill> {
134        let mut indices: Vec<usize> = tags
135            .iter()
136            .filter_map(|tag| self.tag_index.get(tag))
137            .flatten()
138            .copied()
139            .collect();
140
141        indices.sort_unstable();
142        indices.dedup();
143
144        indices.sort_by(|&a, &b| {
145            let sa = &self.skills[a];
146            let sb = &self.skills[b];
147            sb.tier
148                .cmp(&sa.tier)
149                .then_with(|| sb.priority.cmp(&sa.priority))
150        });
151
152        indices.iter().map(|&i| &self.skills[i].skill).collect()
153    }
154
155    /// Find skills whose trigger keywords match the query (case-insensitive).
156    pub fn find_by_trigger(&self, query: &str) -> Vec<&Skill> {
157        let query_lower = query.to_lowercase();
158        let mut indices: Vec<usize> = self
159            .trigger_index
160            .iter()
161            .filter(|(keyword, _)| query_lower.contains(keyword.as_str()))
162            .flat_map(|(_, idxs)| idxs.iter().copied())
163            .collect();
164
165        indices.sort_unstable();
166        indices.dedup();
167
168        indices.sort_by(|&a, &b| {
169            let sa = &self.skills[a];
170            let sb = &self.skills[b];
171            sb.tier
172                .cmp(&sa.tier)
173                .then_with(|| sb.priority.cmp(&sa.priority))
174        });
175
176        indices.iter().map(|&i| &self.skills[i].skill).collect()
177    }
178
179    /// Get all loaded skills.
180    pub fn skills(&self) -> Vec<&Skill> {
181        self.skills.iter().map(|s| &s.skill).collect()
182    }
183
184    /// Number of loaded skills.
185    pub fn len(&self) -> usize {
186        self.skills.len()
187    }
188
189    /// Whether the registry is empty.
190    pub fn is_empty(&self) -> bool {
191        self.skills.is_empty()
192    }
193}
194
195// ── Skill handler (hot-reload) ─────────────────────────────────────────
196
197/// Skill registry owner with hot-reload support.
198///
199/// Implements [`Hook`] — `on_build_agent` enriches the system prompt with
200/// matching skills based on agent tags. Tools and dispatch are no-ops
201/// (skills inject behavior via prompt, not via tools).
202pub struct SkillHandler {
203    skills_dir: PathBuf,
204    registry: RwLock<SkillRegistry>,
205}
206
207impl SkillHandler {
208    /// Load skills from the given directory. Tolerates a missing directory
209    /// by creating an empty registry.
210    pub fn load(skills_dir: PathBuf) -> Result<Self> {
211        let registry = if skills_dir.exists() {
212            match loader::load_skills_dir(&skills_dir, SkillTier::Workspace) {
213                Ok(r) => {
214                    tracing::info!("loaded {} skill(s)", r.len());
215                    r
216                }
217                Err(e) => {
218                    tracing::warn!("could not load skills from {}: {e}", skills_dir.display());
219                    SkillRegistry::new()
220                }
221            }
222        } else {
223            SkillRegistry::new()
224        };
225        Ok(Self {
226            skills_dir,
227            registry: RwLock::new(registry),
228        })
229    }
230
231    /// Reload skills from disk, replacing the entire registry.
232    /// Returns the number of skills loaded.
233    pub async fn reload(&self) -> Result<usize> {
234        let registry = if self.skills_dir.exists() {
235            loader::load_skills_dir(&self.skills_dir, SkillTier::Workspace)?
236        } else {
237            SkillRegistry::new()
238        };
239        let count = registry.len();
240        *self.registry.write().await = registry;
241        Ok(count)
242    }
243
244    /// Access the skill registry lock for read.
245    pub fn registry(&self) -> &RwLock<SkillRegistry> {
246        &self.registry
247    }
248}
249
250impl Hook for SkillHandler {
251    fn on_build_agent(&self, mut config: wcore::AgentConfig) -> wcore::AgentConfig {
252        if let Ok(skills) = self.registry.try_read() {
253            for skill in skills.find_by_tags(&config.skill_tags) {
254                if !skill.body.is_empty() {
255                    config.system_prompt.push_str("\n\n");
256                    config.system_prompt.push_str(&skill.body);
257                }
258            }
259        }
260        config
261    }
262}