use std::collections::HashMap;
use std::path::{Path, PathBuf};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[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),
}
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())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SkillOutcome {
Success,
Failure,
Partial,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationTurn {
pub role: String,
pub content: String,
pub tool_calls: Vec<String>,
}
#[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>,
}
impl Skill {
pub fn total_outcomes(&self) -> u32 {
self.success_count + self.failure_count
}
pub fn recompute_confidence(&mut self) {
let total = self.total_outcomes();
self.confidence = (self.success_count as f64 + 1.0) / (total as f64 + 2.0);
}
}
#[derive(Debug, Clone)]
pub struct SkillEngine {
skills: HashMap<String, Skill>,
skills_dir: PathBuf,
}
impl Default for SkillEngine {
fn default() -> Self {
let dir = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".rx4")
.join("skills");
Self::new(dir)
}
}
impl SkillEngine {
pub fn new(skills_dir: PathBuf) -> Self {
Self {
skills: HashMap::new(),
skills_dir,
}
}
pub fn skills_dir(&self) -> &Path {
&self.skills_dir
}
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),
};
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,
};
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 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() {
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> {
if !self.skills_dir.exists() {
return Ok(());
}
self.skills.clear();
for entry in std::fs::read_dir(&self.skills_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let data = std::fs::read_to_string(&path)?;
let skill: Skill = serde_json::from_str(&data)?;
self.skills.insert(skill.id.clone(), skill);
}
Ok(())
}
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)
}
#[derive(Debug, Default, Clone)]
pub struct SkillRegistry {
skills: Vec<Skill>,
}
impl SkillRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, skill: Skill) {
self.skills.push(skill);
}
pub fn match_prompt(&self, prompt: &str) -> Vec<&Skill> {
let p = prompt.to_lowercase();
self.skills
.iter()
.filter(|s| {
s.trigger_patterns
.iter()
.any(|t| p.contains(&t.to_lowercase()))
})
.collect()
}
pub fn auto_activate(&self, prompt: &str) -> Vec<String> {
let mut matched: Vec<&Skill> = self.match_prompt(prompt);
matched.sort_by(|a, b| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
});
matched.iter().map(|s| s.instructions.clone()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn sample_conversation() -> Vec<ConversationTurn> {
vec![
ConversationTurn {
role: "user".into(),
content: "Find the failing test and fix the assertion in parser.rs".into(),
tool_calls: vec![],
},
ConversationTurn {
role: "assistant".into(),
content: "I'll search for the failing test.".into(),
tool_calls: vec!["grep".into()],
},
ConversationTurn {
role: "assistant".into(),
content: "Now let me read the file.".into(),
tool_calls: vec!["read".into()],
},
ConversationTurn {
role: "assistant".into(),
content: "Applying the fix.".into(),
tool_calls: vec!["edit".into()],
},
ConversationTurn {
role: "assistant".into(),
content: "The assertion is fixed.".into(),
tool_calls: vec![],
},
]
}
#[test]
fn test_create_from_pattern() {
let mut engine = SkillEngine::new(PathBuf::from("/tmp/rx4-skills-test"));
let skill = engine
.create_from_pattern(
"refactor_module",
"Refactor a Rust module",
"1. Identify module\n2. Extract traits",
vec!["refactor".into(), "module".into()],
)
.expect("create_from_pattern");
assert_eq!(skill.name, "refactor_module");
assert_eq!(skill.trigger_patterns.len(), 2);
assert_eq!(skill.confidence, 0.5);
assert!(engine.get(&skill.id).is_some());
}
#[test]
fn test_create_from_pattern_rejects_empty_name() {
let mut engine = SkillEngine::new(PathBuf::from("/tmp/rx4-skills-test"));
let res = engine.create_from_pattern("", "d", "i", vec![]);
assert!(matches!(res, Err(SkillError::Extraction(_))));
}
#[test]
fn test_create_from_conversation() {
let mut engine = SkillEngine::new(PathBuf::from("/tmp/rx4-skills-test"));
let conv = sample_conversation();
let skill = engine
.create_from_conversation(&conv, SkillOutcome::Success)
.expect("create_from_conversation");
assert!(!skill.trigger_patterns.is_empty());
assert!(skill.trigger_patterns.iter().any(|t| t.contains("failing")));
assert!(skill.instructions.contains("1."));
assert!(skill.source_conversation.is_some());
assert!(skill.source_conversation.as_ref().unwrap().contains("grep"));
assert_eq!(skill.success_count, 1);
}
#[test]
fn test_create_from_conversation_empty() {
let mut engine = SkillEngine::new(PathBuf::from("/tmp/rx4-skills-test"));
let res = engine.create_from_conversation(&[], SkillOutcome::Success);
assert!(matches!(res, Err(SkillError::Extraction(_))));
}
#[test]
fn test_confidence_bayesian() {
let mut engine = SkillEngine::new(PathBuf::from("/tmp/rx4-skills-test"));
let skill = engine
.create_from_pattern("s", "d", "i", vec![])
.expect("create");
let id = skill.id.clone();
engine.update_confidence(&id, true).expect("ok");
engine.update_confidence(&id, true).expect("ok");
engine.update_confidence(&id, false).expect("ok");
let s = engine.get(&id).expect("exists");
assert_eq!(s.success_count, 2);
assert_eq!(s.failure_count, 1);
let expected = (2.0 + 1.0) / (3.0 + 2.0);
assert!((s.confidence - expected).abs() < 1e-9);
}
#[test]
fn test_confidence_update_missing() {
let mut engine = SkillEngine::new(PathBuf::from("/tmp/rx4-skills-test"));
let res = engine.update_confidence("nope", true);
assert!(matches!(res, Err(SkillError::NotFound(_))));
}
#[test]
fn test_search() {
let mut engine = SkillEngine::new(PathBuf::from("/tmp/rx4-skills-test"));
engine
.create_from_pattern(
"grep_skill",
"search code with grep",
"run grep",
vec!["grep".into()],
)
.expect("c1");
engine
.create_from_pattern(
"edit_skill",
"edit files safely",
"run edit",
vec!["edit".into()],
)
.expect("c2");
let hits = engine.search("grep");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].name, "grep_skill");
let hits2 = engine.search("edit");
assert_eq!(hits2.len(), 1);
assert_eq!(hits2[0].name, "edit_skill");
}
#[test]
fn test_prune() {
let mut engine = SkillEngine::new(PathBuf::from("/tmp/rx4-skills-test"));
let a = engine
.create_from_pattern("low", "d", "i", vec![])
.expect("a");
let b = engine
.create_from_pattern("high", "d", "i", vec![])
.expect("b");
for _ in 0..5 {
engine.update_confidence(&b.id, true).expect("ok");
}
engine.update_confidence(&a.id, false).expect("ok");
let pruned = engine.prune(0.5);
assert!(pruned.contains(&a.id));
assert!(!pruned.contains(&b.id));
assert!(engine.get(&a.id).is_none());
assert!(engine.get(&b.id).is_some());
}
#[test]
fn test_export_markdown_frontmatter() {
let mut engine = SkillEngine::new(PathBuf::from("/tmp/rx4-skills-test"));
let skill = engine
.create_from_pattern(
"md_skill",
"a skill",
"do the thing",
vec!["trigger".into()],
)
.expect("create");
let md = engine.export_markdown(&skill.id).expect("export");
assert!(md.starts_with("---\n"));
assert!(md.contains("id: "));
assert!(md.contains("name: md_skill"));
assert!(md.contains("confidence:"));
assert!(md.contains("# md_skill"));
assert!(md.contains("## Triggers"));
assert!(md.contains("- trigger"));
assert!(md.contains("## Instructions"));
assert!(md.contains("do the thing"));
}
#[test]
fn test_export_markdown_missing() {
let engine = SkillEngine::new(PathBuf::from("/tmp/rx4-skills-test"));
let res = engine.export_markdown("missing");
assert!(matches!(res, Err(SkillError::NotFound(_))));
}
#[test]
fn test_registry_matching() {
let mut engine = SkillEngine::new(PathBuf::from("/tmp/rx4-skills-test"));
let skill = engine
.create_from_pattern(
"fix_test",
"fix failing tests",
"run cargo test",
vec!["failing".into(), "test".into()],
)
.expect("create");
let mut registry = SkillRegistry::new();
registry.register(skill);
let matches = registry.match_prompt("please fix the failing test in parser");
assert_eq!(matches.len(), 1);
let no_matches = registry.match_prompt("deploy the server");
assert!(no_matches.is_empty());
}
#[test]
fn test_auto_activate_sorted_by_confidence() {
let mut registry = SkillRegistry::new();
let mut low = Skill {
id: "low".into(),
name: "low".into(),
description: "d".into(),
trigger_patterns: vec!["deploy".into()],
instructions: "low instructions".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
success_count: 0,
failure_count: 5,
confidence: 0.1,
tags: vec![],
source_conversation: None,
};
low.recompute_confidence();
let mut high = Skill {
id: "high".into(),
name: "high".into(),
description: "d".into(),
trigger_patterns: vec!["deploy".into()],
instructions: "high instructions".into(),
created_at: Utc::now(),
updated_at: Utc::now(),
success_count: 10,
failure_count: 0,
confidence: 0.9,
tags: vec![],
source_conversation: None,
};
high.recompute_confidence();
registry.register(low);
registry.register(high);
let activated = registry.auto_activate("deploy the app");
assert_eq!(activated.len(), 2);
assert_eq!(activated[0], "high instructions");
assert_eq!(activated[1], "low instructions");
}
#[test]
fn test_save_load_roundtrip() {
let dir = tempdir().expect("tempdir");
let mut engine = SkillEngine::new(dir.path().to_path_buf());
let a = engine
.create_from_pattern("alpha", "first skill", "step 1", vec!["a".into()])
.expect("create a");
let b = engine
.create_from_pattern("beta", "second skill", "step 2", vec!["b".into()])
.expect("create b");
engine.save().expect("save");
let mut engine2 = SkillEngine::new(dir.path().to_path_buf());
engine2.load().expect("load");
assert_eq!(engine2.list().len(), 2);
assert!(engine2.get(&a.id).is_some());
assert!(engine2.get(&b.id).is_some());
assert_eq!(engine2.get(&a.id).expect("a").name, "alpha");
}
#[test]
fn test_load_missing_dir_is_ok() {
let mut engine = SkillEngine::new(PathBuf::from("/nonexistent/rx4/skills/xyz"));
let res = engine.load();
assert!(res.is_ok());
assert!(engine.list().is_empty());
}
#[test]
fn test_skill_outcome_serialization() {
let s = serde_json::to_string(&SkillOutcome::Success).expect("ser");
assert_eq!(s, "\"Success\"");
let p: SkillOutcome = serde_json::from_str("\"Partial\"").expect("de");
assert_eq!(p, SkillOutcome::Partial);
let f: SkillOutcome = serde_json::from_str("\"Failure\"").expect("de");
assert_eq!(f, SkillOutcome::Failure);
let round: SkillOutcome =
serde_json::from_str(&serde_json::to_string(&SkillOutcome::Failure).expect("ser"))
.expect("round");
assert_eq!(round, SkillOutcome::Failure);
}
}