use std::path::PathBuf;
use std::sync::Arc;
use crate::error::SkillError;
use crate::evaluator::{SkillEvaluationRequest, SkillEvaluator, SkillVerdict};
use crate::generator::{SkillGenerationRequest, SkillGenerator};
use crate::registry::SkillRegistry;
static DOMAIN_KEYWORDS: &[(&str, &str)] = &[
("rust", "rust"),
("python", "python"),
("docker", "docker"),
("git", "git"),
("sql", "sql"),
("http", "http"),
("kubernetes", "kubernetes"),
("k8s", "kubernetes"),
("typescript", "typescript"),
("go", "go"),
("golang", "go"),
("terraform", "terraform"),
("react", "react"),
("postgres", "postgres"),
("postgresql", "postgres"),
("bash", "bash"),
("shell", "bash"),
("yaml", "yaml"),
("json", "json"),
("toml", "toml"),
("grpc", "grpc"),
("redis", "redis"),
("kafka", "kafka"),
("aws", "aws"),
("gcp", "gcp"),
("azure", "azure"),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DomainLabel(pub String);
impl DomainLabel {
#[must_use]
pub fn to_skill_name(&self) -> String {
format!("world-knowledge-{}", self.0)
}
}
pub struct ProactiveExplorer {
generator: SkillGenerator,
evaluator: Option<Arc<SkillEvaluator>>,
output_dir: PathBuf,
max_chars: usize,
timeout_ms: u64,
excluded_domains: Vec<String>,
}
impl ProactiveExplorer {
#[must_use]
pub fn new(
generator: SkillGenerator,
evaluator: Option<Arc<SkillEvaluator>>,
output_dir: PathBuf,
max_chars: usize,
timeout_ms: u64,
excluded_domains: Vec<String>,
) -> Self {
Self {
generator,
evaluator,
output_dir,
max_chars,
timeout_ms,
excluded_domains,
}
}
#[must_use]
pub fn timeout_ms(&self) -> u64 {
self.timeout_ms
}
#[tracing::instrument(name = "core.proactive.classify", skip_all)]
pub fn classify(&self, query: &str) -> Option<DomainLabel> {
let lower = query.to_lowercase();
for token in lower.split_whitespace() {
let token = token.trim_end_matches(|c: char| !c.is_alphanumeric());
for &(keyword, domain) in DOMAIN_KEYWORDS {
if token == keyword {
return Some(DomainLabel(domain.to_string()));
}
}
}
None
}
#[must_use]
pub fn has_knowledge(&self, registry: &SkillRegistry, domain: &DomainLabel) -> bool {
let name = domain.to_skill_name();
registry.all_meta().iter().any(|m| m.name == name)
}
#[must_use]
pub fn is_excluded(&self, domain: &DomainLabel) -> bool {
self.excluded_domains.iter().any(|e| e == &domain.0)
}
#[tracing::instrument(name = "core.proactive.explore", skip_all, fields(domain = %domain.0))]
pub async fn explore(&self, domain: &DomainLabel) -> Result<(), SkillError> {
let description = format!(
"World-knowledge reference skill for {domain}. \
Provide concise, authoritative quick-reference information about {domain}: \
key commands, idioms, and best practices. Keep the body under {max_chars} characters.",
domain = domain.0,
max_chars = self.max_chars,
);
let req = SkillGenerationRequest {
description: description.clone(),
category: Some("dev".into()),
allowed_tools: vec![],
};
let skill = self.generator.generate(req).await?;
if let Some(ref evaluator) = self.evaluator {
let eval_req = SkillEvaluationRequest {
name: &skill.name,
description: &skill.meta.description,
body: &skill.content,
original_intent: &description,
};
match evaluator.evaluate(&eval_req).await? {
SkillVerdict::Accept(_) | SkillVerdict::AcceptOnEvalError(_) => {}
SkillVerdict::Reject { score: _, reason } => {
tracing::info!(
domain = %domain.0,
%reason,
"proactive skill rejected by evaluator — skipping write"
);
return Ok(());
}
}
}
let skill_dir = self.output_dir.join(&skill.name);
if skill_dir.exists() {
tracing::debug!(
domain = %domain.0,
skill = %skill.name,
"proactive skill already exists, skipping"
);
return Ok(());
}
tokio::fs::create_dir_all(&skill_dir).await?;
let skill_path = skill_dir.join("SKILL.md");
tokio::fs::write(&skill_path, &skill.content).await?;
tracing::info!(
domain = %domain.0,
skill = %skill.name,
path = %skill_path.display(),
"proactive skill written to disk"
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_rust_query() {
let generator = SkillGenerator::new(
zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
PathBuf::from("/tmp"),
);
let explorer = ProactiveExplorer::new(
generator,
None,
PathBuf::from("/tmp"),
8_000,
30_000,
vec![],
);
let label = explorer.classify("how do I use rust async");
assert_eq!(label, Some(DomainLabel("rust".into())));
}
#[test]
fn classify_returns_none_for_unknown_domain() {
let generator = SkillGenerator::new(
zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
PathBuf::from("/tmp"),
);
let explorer = ProactiveExplorer::new(
generator,
None,
PathBuf::from("/tmp"),
8_000,
30_000,
vec![],
);
assert_eq!(explorer.classify("how are you today"), None);
}
#[test]
fn classify_docker_with_punctuation() {
let generator = SkillGenerator::new(
zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
PathBuf::from("/tmp"),
);
let explorer = ProactiveExplorer::new(
generator,
None,
PathBuf::from("/tmp"),
8_000,
30_000,
vec![],
);
let label = explorer.classify("docker, how do I mount volumes?");
assert_eq!(label, Some(DomainLabel("docker".into())));
}
#[test]
fn is_excluded_matches_configured_domains() {
let generator = SkillGenerator::new(
zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
PathBuf::from("/tmp"),
);
let explorer = ProactiveExplorer::new(
generator,
None,
PathBuf::from("/tmp"),
8_000,
30_000,
vec!["rust".into(), "go".into()],
);
assert!(explorer.is_excluded(&DomainLabel("rust".into())));
assert!(explorer.is_excluded(&DomainLabel("go".into())));
assert!(!explorer.is_excluded(&DomainLabel("python".into())));
}
#[test]
fn domain_label_to_skill_name() {
assert_eq!(
DomainLabel("rust".into()).to_skill_name(),
"world-knowledge-rust"
);
assert_eq!(
DomainLabel("kubernetes".into()).to_skill_name(),
"world-knowledge-kubernetes"
);
}
#[test]
fn has_knowledge_empty_registry() {
let registry = SkillRegistry::load(&[] as &[std::path::PathBuf]);
let generator = SkillGenerator::new(
zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
PathBuf::from("/tmp"),
);
let explorer = ProactiveExplorer::new(
generator,
None,
PathBuf::from("/tmp"),
8_000,
30_000,
vec![],
);
assert!(!explorer.has_knowledge(®istry, &DomainLabel("rust".into())));
}
}