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}