use std::process::Stdio;
use std::sync::{Arc, OnceLock, RwLock};
use std::time::Duration;
use tokio::io::AsyncWriteExt;
#[derive(Debug, Clone)]
struct PoolEntry {
name: String,
rules: Vec<Vec<super::skill::ActivateCheck>>,
}
struct SkillPool {
entries: Vec<PoolEntry>,
}
static SKILL_POOL: OnceLock<Arc<RwLock<Option<SkillPool>>>> = OnceLock::new();
fn get_pool() -> &'static Arc<RwLock<Option<SkillPool>>> {
SKILL_POOL.get_or_init(|| Arc::new(RwLock::new(None)))
}
pub async fn load_env_skills(session: &mut crate::session::chat::session::ChatSession) {
let env_val = match std::env::var("OCTOMIND_SKILLS") {
Ok(v) if !v.trim().is_empty() => v,
_ => return,
};
let skill_names: Vec<&str> = env_val
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if skill_names.is_empty() {
return;
}
let existing: std::collections::HashSet<String> = session
.session
.messages
.iter()
.filter(|m| m.role == "user")
.filter_map(|m| super::skill::extract_skill_name(&m.content).map(String::from))
.collect();
for name in &skill_names {
if existing.contains(*name) {
if let Some(sid) = crate::session::context::current_session_id() {
crate::session::context::add_active_skill(&sid, name);
}
continue;
}
let call = crate::mcp::McpToolCall {
tool_name: "skill".to_string(),
tool_id: format!("env_{}", name),
parameters: serde_json::json!({"action": "use_silent", "name": name}),
};
match super::skill::execute_skill_tool(&call).await {
Ok(_) => {
if let Some(content) = super::skill::take_silent_skill_content() {
let _ = session.add_user_message(&content);
}
}
Err(e) => {
eprintln!("OCTOMIND_SKILLS: skill '{}' failed: {}", name, e);
}
}
}
}
pub fn init_pool(domain: &str) {
let taps = match crate::agent::taps::get_taps() {
Ok(t) => t,
Err(e) => {
crate::log_debug!("skill_auto: failed to load taps: {}", e);
return;
}
};
let mut entries = Vec::new();
let mut seen_names = std::collections::HashSet::new();
for tap in &taps {
let skills_dir = match tap.skills_dir() {
Ok(d) if d.exists() => d,
_ => continue,
};
let dir_entries = match std::fs::read_dir(&skills_dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in dir_entries.flatten() {
let skill_dir = entry.path();
if !skill_dir.is_dir() {
continue;
}
let skill_md = skill_dir.join("SKILL.md");
let content = match std::fs::read_to_string(&skill_md) {
Ok(c) => c,
Err(_) => continue,
};
let meta = match super::skill::parse_skill_meta(&content) {
Some(m) => m,
None => continue,
};
if meta.rules.is_empty() {
continue;
}
if meta.domains.is_empty() || !meta.domains.iter().any(|d| d == domain) {
continue;
}
if seen_names.insert(meta.name.clone()) {
entries.push(PoolEntry {
name: meta.name,
rules: meta.rules,
});
}
}
}
let workdir = crate::mcp::workdir::get_thread_working_directory();
for dir in super::skill::universal_skill_dirs(&workdir) {
let dir_entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in dir_entries.flatten() {
let skill_dir = entry.path();
if !skill_dir.is_dir() {
continue;
}
let skill_md = skill_dir.join("SKILL.md");
let content = match std::fs::read_to_string(&skill_md) {
Ok(c) => c,
Err(_) => continue,
};
let meta = match super::skill::parse_skill_meta(&content) {
Some(m) => m,
None => continue,
};
if meta.rules.is_empty() {
continue;
}
if meta.domains.is_empty() || !meta.domains.iter().any(|d| d == domain) {
continue;
}
if seen_names.insert(meta.name.clone()) {
entries.push(PoolEntry {
name: meta.name,
rules: meta.rules,
});
}
}
}
crate::log_debug!(
"skill_auto: initialized pool with {} skills for domain '{}'",
entries.len(),
domain
);
{
let mut retries = get_retry_tracker().write().unwrap();
retries.clear();
}
let mut pool = get_pool().write().unwrap();
*pool = Some(SkillPool { entries });
}
fn get_skills_config() -> crate::config::SkillsConfig {
crate::session::context::current_session_id()
.and_then(|sid| crate::session::context::get_session_config(&sid))
.map(|cfg| cfg.skills.clone())
.unwrap_or(crate::config::SkillsConfig {
auto_activation: true,
auto_validation: true,
activation_timeout: 3,
validation_timeout: 60,
max_retries: 3,
})
}
pub async fn run_activation(
content: &str,
workdir: &std::path::Path,
session: &mut crate::session::chat::session::ChatSession,
) {
let skills_config = get_skills_config();
if !skills_config.auto_activation {
return;
}
let session_id = match crate::session::context::current_session_id() {
Some(id) => id,
None => return,
};
let entries = {
let pool = get_pool().read().unwrap();
match pool.as_ref() {
Some(p) => p.entries.clone(),
None => return,
}
};
if entries.is_empty() {
return;
}
let active_skills = crate::session::context::get_active_skills(&session_id);
let session_name = session.session.info.name.clone();
for entry in &entries {
if active_skills.contains(&entry.name) {
continue;
}
let mut matched: Option<String> = None;
for group in &entry.rules {
if group
.iter()
.all(|check| check.matches(content, workdir, &session_name))
{
matched = Some(
group
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(" "),
);
break;
}
}
if let Some(trigger) = matched {
crate::log_debug!("skill_auto: activated '{}' via [{}]", entry.name, trigger);
auto_activate_skill(&entry.name, &trigger, session).await;
} else {
crate::log_debug!("skill_auto: no rule matched for '{}'", entry.name);
}
}
}
async fn auto_activate_skill(
name: &str,
trigger: &str,
session: &mut crate::session::chat::session::ChatSession,
) {
let call = crate::mcp::McpToolCall {
tool_name: "skill".to_string(),
tool_id: format!("auto_{}", name),
parameters: serde_json::json!({
"action": "use_silent",
"name": name
}),
};
match super::skill::execute_skill_tool(&call).await {
Ok(_) => {
if let Some(content) = super::skill::take_silent_skill_content() {
let _ = session.add_user_message(&content);
}
if std::io::IsTerminal::is_terminal(&std::io::stderr()) {
use colored::Colorize;
eprintln!(
"{} {} {}",
"Using skill:".dimmed(),
name.bright_cyan(),
format!("[{}]", trigger).dimmed()
);
}
}
Err(e) => {
crate::log_debug!("skill_auto: failed to activate '{}': {}", name, e);
}
}
}
static VALIDATOR_RETRIES: OnceLock<Arc<RwLock<std::collections::HashMap<String, u32>>>> =
OnceLock::new();
fn get_retry_tracker() -> &'static Arc<RwLock<std::collections::HashMap<String, u32>>> {
VALIDATOR_RETRIES.get_or_init(|| Arc::new(RwLock::new(std::collections::HashMap::new())))
}
pub async fn run_validators(content: &str, workdir: &std::path::Path) -> Vec<(String, String)> {
let skills_config = get_skills_config();
if !skills_config.auto_validation {
return Vec::new();
}
let session_id = match crate::session::context::current_session_id() {
Some(id) => id,
None => return Vec::new(),
};
let active_skills = crate::session::context::get_active_skills(&session_id);
if active_skills.is_empty() {
return Vec::new();
}
let timeout = if skills_config.validation_timeout == 0 {
Duration::from_secs(3600) } else {
Duration::from_secs(skills_config.validation_timeout)
};
let max_retries = skills_config.max_retries;
let taps = match crate::agent::taps::get_taps() {
Ok(t) => t,
Err(_) => return Vec::new(),
};
let mut tasks = Vec::new();
let retry_tracker = get_retry_tracker();
let mut scheduled_names: Vec<String> = Vec::new();
for skill_name in &active_skills {
if max_retries > 0 {
let retries = retry_tracker.read().unwrap();
if let Some(&count) = retries.get(skill_name) {
if count >= max_retries {
crate::log_debug!(
"skill_auto: validator '{}' exceeded max_retries ({}), skipping",
skill_name,
max_retries
);
continue;
}
}
}
for tap in &taps {
let skills_dir = match tap.skills_dir() {
Ok(d) if d.exists() => d,
_ => continue,
};
let skill_dir = skills_dir.join(skill_name);
if !skill_dir.is_dir() {
continue;
}
let validate_script = skill_dir.join("validate");
if !validate_script.exists() {
break; }
let content = content.to_string();
let workdir = workdir.to_path_buf();
let name = skill_name.clone();
scheduled_names.push(skill_name.clone());
tasks.push(tokio::spawn(async move {
let result =
run_validate_script(&validate_script, &content, &workdir, timeout).await;
(name, result)
}));
break; }
}
if tasks.is_empty() {
return Vec::new();
}
let phase_label = format!("Validating ({})…", scheduled_names.join(", "));
crate::session::chat::animation_manager::get_animation_manager()
.set_phase(&phase_label)
.await;
let mut failures = Vec::new();
for task in tasks {
match task.await {
Ok((name, Ok((exit_code, stderr)))) => {
if exit_code != 0 && !stderr.is_empty() {
let mut retries = retry_tracker.write().unwrap();
let count = retries.entry(name.clone()).or_insert(0);
*count += 1;
failures.push((name, stderr));
} else if exit_code == 0 {
let mut retries = retry_tracker.write().unwrap();
retries.remove(&name);
}
}
Ok((name, Err(e))) => {
crate::log_debug!("skill_auto: '{}' validate script error: {}", name, e);
}
Err(e) => {
crate::log_debug!("skill_auto: validator task join error: {}", e);
}
}
}
crate::session::chat::animation_manager::get_animation_manager().clear_phase();
failures
}
async fn run_validate_script(
script_path: &std::path::Path,
content: &str,
workdir: &std::path::Path,
timeout: Duration,
) -> anyhow::Result<(i32, String)> {
let mut child = tokio::process::Command::new(script_path)
.arg("assistant")
.current_dir(workdir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to spawn {}: {}", script_path.display(), e))?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(content.as_bytes()).await;
drop(stdin);
}
match tokio::time::timeout(timeout, child.wait_with_output()).await {
Ok(Ok(output)) => {
let exit_code = output.status.code().unwrap_or(1);
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let error_output = if stderr.trim().is_empty() {
String::from_utf8_lossy(&output.stdout).to_string()
} else {
stderr
};
Ok((exit_code, error_output))
}
Ok(Err(e)) => Err(anyhow::anyhow!("Script wait error: {}", e)),
Err(_) => Err(anyhow::anyhow!("Validator timed out")),
}
}