use std::collections::HashMap;
use std::path::{Path, PathBuf};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SkillFrontmatter {
pub id: Option<String>,
pub name: Option<String>,
pub description: Option<String>,
pub confidence: Option<f64>,
pub success_count: Option<u32>,
pub failure_count: Option<u32>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub tags: Option<Vec<String>>,
pub trigger_patterns: Option<Vec<String>>,
pub category: Option<String>,
pub related_skills: Option<Vec<String>>,
}
#[derive(Debug, Error)]
pub enum SkillError {
#[error("io error: {0}")]
Io(String),
#[error("skill not found: {0}")]
NotFound(String),
#[error("serialization error: {0}")]
Serialization(String),
#[error("extraction error: {0}")]
Extraction(String),
#[error("yaml error: {0}")]
Yaml(String),
#[error("parse error: {0}")]
Parse(String),
}
impl From<std::io::Error> for SkillError {
fn from(err: std::io::Error) -> Self {
SkillError::Io(err.to_string())
}
}
impl From<serde_json::Error> for SkillError {
fn from(err: serde_json::Error) -> Self {
SkillError::Serialization(err.to_string())
}
}
impl From<serde_yaml::Error> for SkillError {
fn from(err: serde_yaml::Error) -> Self {
SkillError::Yaml(err.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SkillOutcome {
Success,
Failure,
Partial,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum SkillState {
#[default]
Active,
Stale,
Archived,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationTurn {
pub role: String,
pub content: String,
pub tool_calls: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct ConfidencePrior {
pub alpha: f64,
pub beta: f64,
}
impl Default for ConfidencePrior {
fn default() -> Self {
Self {
alpha: 1.0,
beta: 1.0,
}
}
}
impl ConfidencePrior {
pub fn mean(&self, successes: u32, failures: u32) -> f64 {
let a = self.alpha + f64::from(successes);
let b = self.beta + f64::from(failures);
if a + b <= 0.0 {
return 0.5;
}
a / (a + b)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Skill {
pub id: String,
pub name: String,
pub description: String,
pub trigger_patterns: Vec<String>,
pub instructions: String,
pub created_at: chrono::DateTime<Utc>,
pub updated_at: chrono::DateTime<Utc>,
pub success_count: u32,
pub failure_count: u32,
pub confidence: f64,
pub tags: Vec<String>,
pub source_conversation: Option<String>,
#[serde(default)]
pub pinned: bool,
#[serde(default)]
pub state: SkillState,
}
impl Skill {
pub fn total_outcomes(&self) -> u32 {
self.success_count + self.failure_count
}
pub fn recompute_confidence(&mut self) {
self.recompute_confidence_with_prior(ConfidencePrior::default());
}
pub fn recompute_confidence_with_prior(&mut self, prior: ConfidencePrior) {
self.confidence = prior.mean(self.success_count, self.failure_count);
}
}
#[derive(Debug, Clone)]
pub struct SkillEngine {
skills: HashMap<String, Skill>,
skills_dir: PathBuf,
extra_dirs: Vec<PathBuf>,
}
impl Default for SkillEngine {
fn default() -> Self {
let dir = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".agents")
.join("skills");
Self::new(dir)
}
}
impl SkillEngine {
pub fn new(skills_dir: PathBuf) -> Self {
Self {
skills: HashMap::new(),
skills_dir,
extra_dirs: Vec::new(),
}
}
pub fn skills_dir(&self) -> &Path {
&self.skills_dir
}
pub fn add_extra_dir(&mut self, dir: PathBuf) {
if !self.extra_dirs.contains(&dir) {
self.extra_dirs.push(dir);
}
}
pub fn extra_dirs(&self) -> &[PathBuf] {
&self.extra_dirs
}
#[cfg(test)]
pub fn insert_skill_for_test(&mut self, skill: Skill) {
self.skills.insert(skill.id.clone(), skill);
}
pub fn create_from_conversation(
&mut self,
conversation: &[ConversationTurn],
outcome: SkillOutcome,
) -> Result<Skill, SkillError> {
if conversation.is_empty() {
return Err(SkillError::Extraction(
"cannot extract a skill from an empty conversation".into(),
));
}
let first_user = conversation
.iter()
.find(|t| t.role.eq_ignore_ascii_case("user"))
.ok_or_else(|| SkillError::Extraction("no user turn found in conversation".into()))?;
let intent = extract_intent(&first_user.content);
let name = derive_name(&intent);
let description = summarize_conversation(conversation);
let trigger_patterns = extract_trigger_patterns(&first_user.content);
let tags = extract_tags(&first_user.content);
let instructions = generate_instructions(conversation);
let tool_sequence = extract_tool_sequence(conversation);
let id = uuid::Uuid::new_v4().to_string();
let now = Utc::now();
let (success_count, failure_count) = match outcome {
SkillOutcome::Success => (1, 0),
SkillOutcome::Failure => (0, 1),
SkillOutcome::Partial => (0, 0),
};
let mut skill = Skill {
id: id.clone(),
name,
description,
trigger_patterns,
instructions,
created_at: now,
updated_at: now,
success_count,
failure_count,
confidence: 0.5,
tags,
source_conversation: Some(tool_sequence),
pinned: false,
state: SkillState::Active,
};
skill.recompute_confidence();
self.skills.insert(id.clone(), skill.clone());
Ok(skill)
}
pub fn create_from_pattern(
&mut self,
name: &str,
description: &str,
instructions: &str,
triggers: Vec<String>,
) -> Result<Skill, SkillError> {
if name.trim().is_empty() {
return Err(SkillError::Extraction("skill name is required".into()));
}
let id = uuid::Uuid::new_v4().to_string();
let now = Utc::now();
let skill = Skill {
id: id.clone(),
name: name.to_string(),
description: description.to_string(),
trigger_patterns: triggers,
instructions: instructions.to_string(),
created_at: now,
updated_at: now,
success_count: 0,
failure_count: 0,
confidence: 0.5,
tags: Vec::new(),
source_conversation: None,
pinned: false,
state: SkillState::Active,
};
self.skills.insert(id.clone(), skill.clone());
Ok(skill)
}
pub fn get(&self, id: &str) -> Option<&Skill> {
self.skills.get(id)
}
pub fn list(&self) -> Vec<&Skill> {
self.skills.values().collect()
}
pub fn search(&self, query: &str) -> Vec<&Skill> {
let q = query.to_lowercase();
let terms: Vec<&str> = q.split_whitespace().collect();
if terms.is_empty() {
return Vec::new();
}
self.skills
.values()
.filter(|s| {
terms.iter().all(|term| {
s.name.to_lowercase().contains(term)
|| s.description.to_lowercase().contains(term)
|| s.tags.iter().any(|t| t.to_lowercase().contains(term))
|| s.trigger_patterns
.iter()
.any(|t| t.to_lowercase().contains(term))
})
})
.collect()
}
pub fn update_confidence(&mut self, id: &str, success: bool) -> Result<(), SkillError> {
let skill = self
.skills
.get_mut(id)
.ok_or_else(|| SkillError::NotFound(id.to_string()))?;
if success {
skill.success_count += 1;
} else {
skill.failure_count += 1;
}
skill.updated_at = Utc::now();
skill.recompute_confidence();
Ok(())
}
pub fn set_state(&mut self, id: &str, state: SkillState) -> Result<(), SkillError> {
let skill = self
.skills
.get_mut(id)
.ok_or_else(|| SkillError::NotFound(id.to_string()))?;
skill.state = state;
skill.updated_at = Utc::now();
Ok(())
}
pub fn set_pinned(&mut self, id: &str, pinned: bool) -> Result<(), SkillError> {
let skill = self
.skills
.get_mut(id)
.ok_or_else(|| SkillError::NotFound(id.to_string()))?;
skill.pinned = pinned;
Ok(())
}
pub fn merge_skill(&mut self, source_id: &str, target_id: &str) -> Result<(), SkillError> {
if source_id == target_id {
return Err(SkillError::Extraction(
"cannot merge a skill into itself".into(),
));
}
let source = self
.skills
.remove(source_id)
.ok_or_else(|| SkillError::NotFound(source_id.to_string()))?;
let target = self
.skills
.get_mut(target_id)
.ok_or_else(|| SkillError::NotFound(target_id.to_string()))?;
for pat in source.trigger_patterns {
if !target.trigger_patterns.contains(&pat) {
target.trigger_patterns.push(pat);
}
}
for tag in source.tags {
if !target.tags.contains(&tag) {
target.tags.push(tag);
}
}
if !source.instructions.is_empty() {
if target.instructions.is_empty() {
target.instructions = source.instructions;
} else {
target.instructions = format!("{}\n\n{}", target.instructions, source.instructions);
}
}
target.success_count += source.success_count;
target.failure_count += source.failure_count;
target.recompute_confidence();
target.updated_at = Utc::now();
Ok(())
}
pub fn update_instructions(&mut self, id: &str, instructions: &str) -> Result<(), SkillError> {
let skill = self
.skills
.get_mut(id)
.ok_or_else(|| SkillError::NotFound(id.to_string()))?;
skill.instructions = instructions.to_string();
skill.updated_at = Utc::now();
Ok(())
}
pub fn prune(&mut self, min_confidence: f64) -> Vec<String> {
let to_remove: Vec<String> = self
.skills
.iter()
.filter(|(_, s)| s.confidence < min_confidence)
.map(|(id, _)| id.clone())
.collect();
for id in &to_remove {
self.skills.remove(id);
}
to_remove
}
pub fn save(&self) -> Result<(), SkillError> {
std::fs::create_dir_all(&self.skills_dir)?;
for skill in self.skills.values() {
crate::tools::common::validate_identifier(&skill.id).map_err(|e| {
SkillError::Extraction(format!("invalid skill id '{}': {e}", skill.id))
})?;
let path = self.skills_dir.join(format!("{}.json", skill.id));
let json = serde_json::to_string_pretty(skill)?;
std::fs::write(path, json)?;
}
Ok(())
}
pub fn load(&mut self) -> Result<(), SkillError> {
self.skills.clear();
let mut dirs = vec![self.skills_dir.clone()];
dirs.extend(self.extra_dirs.iter().cloned());
for dir in &dirs {
if !dir.exists() {
continue;
}
self.load_dir(dir)?;
}
Ok(())
}
fn load_dir(&mut self, dir: &Path) -> Result<(), SkillError> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let skill_md = path.join("SKILL.md");
if skill_md.exists() {
let skill = self.import_markdown_file(&skill_md)?;
self.skills.insert(skill.id.clone(), skill);
}
continue;
}
let ext = path.extension().and_then(|e| e.to_str());
match ext {
Some("json") => {
let data = std::fs::read_to_string(&path)?;
let skill: Skill = serde_json::from_str(&data)?;
if let Err(e) = crate::tools::common::validate_identifier(&skill.id) {
tracing::warn!(
"rejecting skill with invalid id '{}' from {}: {e}",
skill.id,
path.display()
);
continue;
}
self.skills.insert(skill.id.clone(), skill);
}
Some("md") => {
let skill = self.import_markdown_file(&path)?;
self.skills.insert(skill.id.clone(), skill);
}
_ => {}
}
}
Ok(())
}
pub fn import_markdown_file(&self, path: &Path) -> Result<Skill, SkillError> {
let content = std::fs::read_to_string(path)?;
Self::parse_skill_markdown(&content)
}
pub fn parse_skill_markdown(content: &str) -> Result<Skill, SkillError> {
let (frontmatter_text, body) = split_frontmatter(content)?;
let mut fm: SkillFrontmatter = if frontmatter_text.trim().is_empty() {
SkillFrontmatter::default()
} else {
serde_yaml::from_str(&frontmatter_text)?
};
let id = fm
.id
.take()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
crate::tools::common::validate_identifier(&id)
.map_err(|e| SkillError::Extraction(format!("invalid skill id '{id}': {e}")))?;
let now = Utc::now();
let created_at = fm
.created_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or(now);
let updated_at = fm
.updated_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or(created_at);
let (name, description, instructions, trigger_patterns) = parse_markdown_body(&body, &fm);
let mut skill = Skill {
id,
name,
description,
trigger_patterns,
instructions,
created_at,
updated_at,
success_count: fm.success_count.unwrap_or(0),
failure_count: fm.failure_count.unwrap_or(0),
confidence: fm.confidence.unwrap_or(0.5),
tags: fm.tags.take().unwrap_or_default(),
source_conversation: None,
pinned: false,
state: SkillState::Active,
};
skill.recompute_confidence();
Ok(skill)
}
pub fn export_markdown(&self, id: &str) -> Result<String, SkillError> {
let skill = self
.get(id)
.ok_or_else(|| SkillError::NotFound(id.to_string()))?;
let frontmatter = format!(
"---\n\
id: {id}\n\
name: {name}\n\
confidence: {confidence:.4}\n\
success_count: {success}\n\
failure_count: {failure}\n\
created_at: {created}\n\
updated_at: {updated}\n\
tags: [{tags}]\n\
---\n",
id = skill.id,
name = skill.name,
confidence = skill.confidence,
success = skill.success_count,
failure = skill.failure_count,
created = skill.created_at.to_rfc3339(),
updated = skill.updated_at.to_rfc3339(),
tags = skill.tags.join(", "),
);
let triggers = skill
.trigger_patterns
.iter()
.map(|t| format!("- {}", t))
.collect::<Vec<_>>()
.join("\n");
let body = format!(
"# {name}\n\n\
{desc}\n\n\
## Triggers\n\n\
{triggers}\n\n\
## Instructions\n\n\
{instructions}\n",
name = skill.name,
desc = skill.description,
triggers = if triggers.is_empty() {
"- (none)".to_string()
} else {
triggers
},
instructions = skill.instructions,
);
Ok(format!("{}{}", frontmatter, body))
}
}
fn extract_intent(content: &str) -> String {
let trimmed = content.trim();
let end = trimmed.find(['.', '!', '?', '\n']).unwrap_or(trimmed.len());
let intent = trimmed[..end].trim();
if intent.is_empty() {
trimmed.to_string()
} else {
intent.to_string()
}
}
fn derive_name(intent: &str) -> String {
let words: Vec<&str> = intent.split_whitespace().take(6).collect();
let joined = words.join("_");
let cleaned: String = joined
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect();
let collapsed = cleaned.trim_matches('_').to_string();
if collapsed.is_empty() {
"unnamed_skill".to_string()
} else {
collapsed
}
}
fn summarize_conversation(conversation: &[ConversationTurn]) -> String {
let user_msgs: Vec<&str> = conversation
.iter()
.filter(|t| t.role.eq_ignore_ascii_case("user"))
.map(|t| t.content.as_str())
.collect();
let tool_count: usize = conversation.iter().map(|t| t.tool_calls.len()).sum();
let first = user_msgs.first().copied().unwrap_or("(no user message)");
format!(
"Skill distilled from a {}-turn conversation ({} tool calls). Intent: {}",
conversation.len(),
tool_count,
truncate(first, 120),
)
}
fn extract_trigger_patterns(content: &str) -> Vec<String> {
let stop = [
"the", "a", "an", "to", "of", "and", "or", "for", "in", "on", "is", "are", "be", "with",
"that", "this", "it", "i", "you", "we", "please", "can", "could", "would", "should", "do",
"does", "my", "me",
];
let mut seen = std::collections::BTreeSet::new();
let mut patterns = Vec::new();
for word in content.split_whitespace() {
let lower = word
.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
.to_lowercase();
if lower.len() < 3 || stop.contains(&lower.as_str()) {
continue;
}
if seen.insert(lower.clone()) {
patterns.push(lower);
}
if patterns.len() >= 8 {
break;
}
}
if patterns.is_empty() {
patterns.push("general".to_string());
}
patterns
}
fn extract_tags(content: &str) -> Vec<String> {
let mut tags = extract_trigger_patterns(content);
tags.truncate(5);
tags
}
fn extract_tool_sequence(conversation: &[ConversationTurn]) -> String {
let seq: Vec<&str> = conversation
.iter()
.flat_map(|t| t.tool_calls.iter().map(String::as_str))
.collect();
seq.join(" -> ")
}
fn generate_instructions(conversation: &[ConversationTurn]) -> String {
let mut steps: Vec<String> = Vec::new();
let mut step_no = 1u32;
for turn in conversation {
if turn.role.eq_ignore_ascii_case("user") {
steps.push(format!(
"{}. Understand the request: {}",
step_no,
truncate(&turn.content, 100)
));
step_no += 1;
} else if turn.role.eq_ignore_ascii_case("assistant") {
if !turn.tool_calls.is_empty() {
let calls = turn.tool_calls.join(", ");
steps.push(format!("{}. Use tools: {}", step_no, calls));
step_no += 1;
}
if !turn.content.trim().is_empty() {
steps.push(format!(
"{}. Respond: {}",
step_no,
truncate(&turn.content, 100)
));
step_no += 1;
}
}
}
if steps.is_empty() {
"No actionable steps could be extracted.".to_string()
} else {
steps.join("\n")
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let cut: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{}…", cut)
}
fn split_frontmatter(content: &str) -> Result<(String, String), SkillError> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return Ok((String::new(), content.to_string()));
}
let after_first = &trimmed[3..];
let end = after_first
.find("\n---")
.ok_or_else(|| SkillError::Parse("frontmatter not closed".into()))?;
let frontmatter = after_first[..end].trim().to_string();
let rest_start = end + 4;
let body = if rest_start < after_first.len() {
after_first[rest_start..].trim_start().to_string()
} else {
String::new()
};
Ok((frontmatter, body))
}
fn parse_markdown_body(body: &str, fm: &SkillFrontmatter) -> (String, String, String, Vec<String>) {
let name = fm.name.clone().unwrap_or_else(|| {
let h1 = body
.lines()
.find(|l| l.starts_with("# "))
.map(|l| l[2..].trim().to_string())
.unwrap_or_else(|| "unnamed_skill".to_string());
h1
});
let description = fm.description.clone().unwrap_or_else(|| {
body.lines()
.find(|l| !l.starts_with('#') && !l.trim().is_empty())
.map(|l| l.trim().to_string())
.unwrap_or_default()
});
let trigger_patterns = fm
.trigger_patterns
.clone()
.unwrap_or_else(|| extract_section_items(body, "Triggers"));
let instructions = extract_section_body(body, "Instructions").unwrap_or_default();
let instructions = if instructions.is_empty() {
body.to_string()
} else {
instructions
};
(name, description, instructions, trigger_patterns)
}
fn extract_section_items(body: &str, section: &str) -> Vec<String> {
let mut items = Vec::new();
let mut in_section = false;
for line in body.lines() {
if let Some(heading) = line.strip_prefix("## ") {
in_section = heading.trim().eq_ignore_ascii_case(section);
continue;
}
if in_section && line.starts_with("- ") {
items.push(line[2..].trim().to_string());
}
}
items
}
fn extract_section_body(body: &str, section: &str) -> Option<String> {
let mut in_section = false;
let mut collected: Vec<&str> = Vec::new();
for line in body.lines() {
if let Some(heading) = line.strip_prefix("## ") {
if in_section {
break;
}
in_section = heading.trim().eq_ignore_ascii_case(section);
continue;
}
if in_section {
collected.push(line);
}
}
if collected.is_empty() {
None
} else {
let text = collected
.join("\n")
.trim()
.trim_start_matches(['-', ' '])
.to_string();
if text.is_empty() {
None
} else {
Some(text)
}
}
}