//! Passive skill awareness: tool-call and event driven activation. use super::{SkillActivation, SkillContext, SkillEntry, ToolCallEvent, normalize_skill_key}; #[derive(Debug, Clone, Default)] pub struct PassiveSkillAwareness; impl PassiveSkillAwareness { pub fn new() -> Self { Self } pub fn detect(&self, event: &ToolCallEvent, skills: &[SkillEntry]) -> Option { let tool_name = event.tool_name.to_lowercase(); let args = event.arguments.to_lowercase(); if let Some(skill) = Self::match_tool_category(&tool_name, skills) { return Some(Self::context_from_skill(skill, "tool category")); } for skill in skills { let slug = normalize_skill_key(&skill.slug); let name = skill.name.to_lowercase(); if Self::slug_in_text(&tool_name, &slug) { return Some(Self::context_from_skill(skill, "tool invocation")); } if Self::slug_in_text(&args, &slug) || Self::keyword_match(&args, &name) { return Some(Self::context_from_skill(skill, "tool arguments")); } } None } pub fn detect_from_text(&self, text: &str, skills: &[SkillEntry]) -> Option { let text_lower = text.to_lowercase(); skills.iter().find_map(|skill| { let slug = normalize_skill_key(&skill.slug); let name = skill.name.to_lowercase(); (Self::slug_in_text(&text_lower, &slug) || Self::keyword_match(&text_lower, &name)) .then(|| Self::context_from_skill(skill, "observation match")) }) } fn match_tool_category<'a>( tool_name: &str, skills: &'a [SkillEntry], ) -> Option<&'a SkillEntry> { const CATEGORY_MAP: &[(&str, &[&str])] = &[ ("git_", &["git"]), ("repo_", &["repo", "repository"]), ("project_", &["repo", "repository", "project"]), ("issue_", &["issue", "triage"]), ("list_issues", &["issue", "triage"]), ("create_issue", &["issue"]), ("update_issue", &["issue"]), ("pr_", &["pr", "pull", "pull-request"]), ("pull_request_", &["pr", "pull", "pull-request"]), ("code_review", &["code-review", "review"]), ("security_", &["security", "review"]), ("test_", &["test", "testing"]), ("read_", &["file", "reader"]), ("git_file", &["file", "reader"]), ("curl", &["http", "api"]), ("project_curl", &["http", "api"]), ]; for (prefix, categories) in CATEGORY_MAP { if tool_name.starts_with(prefix) { if let Some(skill) = skills.iter().find(|skill| { let slug = normalize_skill_key(&skill.slug); let name = skill.name.to_lowercase(); categories .iter() .any(|category| slug.contains(category) || name.contains(category)) }) { return Some(skill); } } } None } fn slug_in_text(text: &str, slug: &str) -> bool { text.contains(slug) || slug .split(['/', '-']) .filter(|seg| seg.len() >= 3) .any(|seg| text.contains(seg)) } fn keyword_match(text: &str, name: &str) -> bool { let significant = name .split(|c: char| !c.is_alphanumeric()) .filter(|w| w.len() >= 3) .collect::>(); if significant.len() >= 2 { significant.iter().all(|w| text.contains(*w)) } else { significant.first().is_some_and(|w| text.contains(w)) } } fn context_from_skill(skill: &SkillEntry, trigger: &str) -> SkillContext { SkillContext::new( skill, SkillActivation::Passive, Some(trigger), format!( "# {} (passive: {})\n\n{}", skill.name, trigger, skill.content ), None, ) } }