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}