- Remove activation threshold logic from PassiveSkillAwareness - Add SkillActivation enum with Priority/Keyword/Vector/Auto variants - Add deduplication via SkillContext.dedupe_key() using rank ordering - Simplify ActiveSkillAwareness with cleaner regex-based detection
120 lines
4.1 KiB
Rust
120 lines
4.1 KiB
Rust
//! 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<SkillContext> {
|
|
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<SkillContext> {
|
|
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::<Vec<_>>();
|
|
|
|
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,
|
|
)
|
|
}
|
|
}
|