Skip to main content

talos_skill/
manager.rs

1use crate::{Result, Skill, SkillError, SkillIndex, SkillLoader, estimate_tokens};
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5/// Manages progressive disclosure of skills across three levels.
6///
7/// `SkillManager` wraps a [`SkillLoader`] and provides on-demand loading
8/// of skill content. Level 0 (index) is always available; Level 1 (full body)
9/// and Level 2 (reference files) are loaded as needed.
10///
11/// # Example
12///
13/// ```no_run
14/// use talos_skill::{SkillLoader, SkillManager};
15///
16/// let loader = SkillLoader::new();
17/// let mut manager = SkillManager::new(loader);
18/// let index = manager.get_index();
19/// ```
20pub struct SkillManager {
21    /// Underlying skill loader for discovery and parsing.
22    loader: SkillLoader,
23    /// Skills loaded at Level 1 or Level 2, keyed by name.
24    active_skills: HashMap<String, Skill>,
25    /// Level 0 index entries, computed on demand.
26    skill_index: Vec<SkillIndex>,
27}
28
29impl SkillManager {
30    /// Creates a new `SkillManager` wrapping the given [`SkillLoader`].
31    ///
32    /// The loader should already have discovered skills via [`SkillLoader::discover`]
33    /// before being passed to this constructor.
34    pub fn new(loader: SkillLoader) -> Self {
35        Self {
36            loader,
37            active_skills: HashMap::new(),
38            skill_index: Vec::new(),
39        }
40    }
41
42    /// Returns the Level 0 index of all discovered skills.
43    ///
44    /// The index is computed lazily on first call and cached. Subsequent calls
45    /// return the cached index unless the underlying loader's skills have changed.
46    pub fn get_index(&mut self) -> &[SkillIndex] {
47        if self.skill_index.is_empty() && !self.loader.skills.is_empty() {
48            self.skill_index = self
49                .loader
50                .skills
51                .iter()
52                .map(|s| {
53                    let level0_text = format!("{}: {}", s.name, s.description);
54                    let estimated_tokens = estimate_tokens(&level0_text);
55                    SkillIndex {
56                        name: s.name.clone(),
57                        description: s.description.clone(),
58                        triggers: s.triggers.clone(),
59                        estimated_tokens,
60                        source: s.source,
61                    }
62                })
63                .collect();
64        }
65        &self.skill_index
66    }
67
68    /// Returns the total estimated token count for the Level 0 index.
69    ///
70    /// Target: <3000 tokens for 20 skills (~150 tokens per skill).
71    pub fn get_index_tokens(&mut self) -> usize {
72        self.get_index().iter().map(|e| e.estimated_tokens).sum()
73    }
74
75    /// Loads a full skill into active memory (Level 1 disclosure).
76    ///
77    /// Searches the loader's discovered skills by name. If found, clones the
78    /// skill into the active set. Returns a reference to the loaded skill.
79    ///
80    /// # Errors
81    ///
82    /// Returns [`SkillError::FileNotFound`] if no skill with the given name
83    /// exists in the loader's discovered skills.
84    pub fn load_skill(&mut self, name: &str) -> Result<&Skill> {
85        if self.active_skills.contains_key(name) {
86            return Ok(self.active_skills.get(name).expect("key exists"));
87        }
88
89        let skill = self
90            .loader
91            .skills
92            .iter()
93            .find(|s| s.name == name)
94            .ok_or_else(|| {
95                SkillError::FileNotFound(PathBuf::from(format!("skill '{name}' not found")))
96            })?;
97
98        let skill = skill.clone();
99        self.active_skills.insert(name.to_string(), skill);
100        Ok(self.active_skills.get(name).expect("key just inserted"))
101    }
102
103    /// Loads a specific reference file from a skill (Level 2 disclosure).
104    ///
105    /// Reads the file at `file_path` relative to the skill's source directory.
106    /// The skill must already be loaded at Level 1 (via [`load_skill`]).
107    ///
108    /// # Errors
109    ///
110    /// Returns [`SkillError::FileNotFound`] if the skill is not loaded or
111    /// the reference file does not exist.
112    pub fn load_reference(&self, skill_name: &str, file_path: &str) -> Result<String> {
113        let skill = self.active_skills.get(skill_name).ok_or_else(|| {
114            SkillError::FileNotFound(PathBuf::from(format!(
115                "skill '{skill_name}' not loaded (call load_skill first)"
116            )))
117        })?;
118
119        let skill_dir = skill.source_path.parent().ok_or_else(|| {
120            SkillError::InvalidFrontmatter("skill has no parent directory".into())
121        })?;
122
123        let ref_path = skill_dir.join(file_path);
124        if !ref_path.exists() {
125            return Err(SkillError::FileNotFound(ref_path));
126        }
127
128        std::fs::read_to_string(&ref_path).map_err(SkillError::IoError)
129    }
130
131    /// Matches a task description to a skill based on trigger keywords.
132    ///
133    /// Returns the name of the first skill whose triggers match the task
134    /// description (case-insensitive substring match). If multiple skills
135    /// match, the first one in discovery order is returned.
136    pub fn match_skill(&self, task_description: &str) -> Option<String> {
137        let task_lower = task_description.to_lowercase();
138
139        self.loader
140            .skills
141            .iter()
142            .find(|skill| {
143                skill.triggers.iter().any(|trigger| {
144                    let trigger_lower = trigger.to_lowercase();
145                    task_lower.contains(&trigger_lower)
146                })
147            })
148            .map(|s| s.name.clone())
149    }
150
151    /// Removes a skill from the active set.
152    ///
153    /// This does not affect the Level 0 index or the loader's discovered skills.
154    /// The skill can be reloaded via [`load_skill`].
155    pub fn unload_skill(&mut self, name: &str) {
156        self.active_skills.remove(name);
157    }
158
159    /// Returns references to all currently active skills.
160    pub fn get_active_skills(&self) -> Vec<&Skill> {
161        self.active_skills.values().collect()
162    }
163}