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                    }
61                })
62                .collect();
63        }
64        &self.skill_index
65    }
66
67    /// Returns the total estimated token count for the Level 0 index.
68    ///
69    /// Target: <3000 tokens for 20 skills (~150 tokens per skill).
70    pub fn get_index_tokens(&mut self) -> usize {
71        self.get_index().iter().map(|e| e.estimated_tokens).sum()
72    }
73
74    /// Loads a full skill into active memory (Level 1 disclosure).
75    ///
76    /// Searches the loader's discovered skills by name. If found, clones the
77    /// skill into the active set. Returns a reference to the loaded skill.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`SkillError::FileNotFound`] if no skill with the given name
82    /// exists in the loader's discovered skills.
83    pub fn load_skill(&mut self, name: &str) -> Result<&Skill> {
84        if self.active_skills.contains_key(name) {
85            return Ok(self.active_skills.get(name).expect("key exists"));
86        }
87
88        let skill = self
89            .loader
90            .skills
91            .iter()
92            .find(|s| s.name == name)
93            .ok_or_else(|| {
94                SkillError::FileNotFound(PathBuf::from(format!("skill '{name}' not found")))
95            })?;
96
97        let skill = skill.clone();
98        self.active_skills.insert(name.to_string(), skill);
99        Ok(self.active_skills.get(name).expect("key just inserted"))
100    }
101
102    /// Loads a specific reference file from a skill (Level 2 disclosure).
103    ///
104    /// Reads the file at `file_path` relative to the skill's source directory.
105    /// The skill must already be loaded at Level 1 (via [`load_skill`]).
106    ///
107    /// # Errors
108    ///
109    /// Returns [`SkillError::FileNotFound`] if the skill is not loaded or
110    /// the reference file does not exist.
111    pub fn load_reference(&self, skill_name: &str, file_path: &str) -> Result<String> {
112        let skill = self.active_skills.get(skill_name).ok_or_else(|| {
113            SkillError::FileNotFound(PathBuf::from(format!(
114                "skill '{skill_name}' not loaded (call load_skill first)"
115            )))
116        })?;
117
118        let skill_dir = skill.source_path.parent().ok_or_else(|| {
119            SkillError::InvalidFrontmatter("skill has no parent directory".into())
120        })?;
121
122        let ref_path = skill_dir.join(file_path);
123        if !ref_path.exists() {
124            return Err(SkillError::FileNotFound(ref_path));
125        }
126
127        std::fs::read_to_string(&ref_path).map_err(SkillError::IoError)
128    }
129
130    /// Matches a task description to a skill based on trigger keywords.
131    ///
132    /// Returns the name of the first skill whose triggers match the task
133    /// description (case-insensitive substring match). If multiple skills
134    /// match, the first one in discovery order is returned.
135    pub fn match_skill(&self, task_description: &str) -> Option<String> {
136        let task_lower = task_description.to_lowercase();
137
138        self.loader
139            .skills
140            .iter()
141            .find(|skill| {
142                skill.triggers.iter().any(|trigger| {
143                    let trigger_lower = trigger.to_lowercase();
144                    task_lower.contains(&trigger_lower)
145                })
146            })
147            .map(|s| s.name.clone())
148    }
149
150    /// Removes a skill from the active set.
151    ///
152    /// This does not affect the Level 0 index or the loader's discovered skills.
153    /// The skill can be reloaded via [`load_skill`].
154    pub fn unload_skill(&mut self, name: &str) {
155        self.active_skills.remove(name);
156    }
157
158    /// Returns references to all currently active skills.
159    pub fn get_active_skills(&self) -> Vec<&Skill> {
160        self.active_skills.values().collect()
161    }
162}