Skip to main content

zeroclaw_runtime/skillforge/
mod.rs

1//! SkillForge — Skill auto-discovery, evaluation, and integration engine.
2//!
3//! Pipeline: Scout → Evaluate → Integrate
4//! Discovers skills from external sources, scores them, and generates
5//! ZeroClaw-compatible manifests for qualified candidates.
6
7pub mod evaluate;
8pub mod integrate;
9pub mod scout;
10
11use anyhow::Result;
12use serde::{Deserialize, Serialize};
13
14use self::evaluate::{EvalResult, Evaluator, Recommendation};
15use self::integrate::Integrator;
16use self::scout::{GitHubScout, Scout, ScoutResult, ScoutSource};
17
18// ---------------------------------------------------------------------------
19// Configuration
20// ---------------------------------------------------------------------------
21
22#[derive(Clone, Serialize, Deserialize)]
23pub struct SkillForgeConfig {
24    #[serde(default)]
25    pub enabled: bool,
26    #[serde(default = "default_auto_integrate")]
27    pub auto_integrate: bool,
28    #[serde(default = "default_sources")]
29    pub sources: Vec<String>,
30    #[serde(default = "default_scan_interval")]
31    pub scan_interval_hours: u64,
32    #[serde(default = "default_min_score")]
33    pub min_score: f64,
34    /// Optional GitHub personal-access token for higher rate limits.
35    #[serde(default)]
36    pub github_token: Option<String>,
37    /// Directory where integrated skills are written.
38    #[serde(default = "default_output_dir")]
39    pub output_dir: String,
40}
41
42fn default_auto_integrate() -> bool {
43    true
44}
45fn default_sources() -> Vec<String> {
46    vec!["github".into(), "clawhub".into()]
47}
48fn default_scan_interval() -> u64 {
49    24
50}
51fn default_min_score() -> f64 {
52    0.7
53}
54fn default_output_dir() -> String {
55    "./skills".into()
56}
57
58impl Default for SkillForgeConfig {
59    fn default() -> Self {
60        Self {
61            enabled: false,
62            auto_integrate: default_auto_integrate(),
63            sources: default_sources(),
64            scan_interval_hours: default_scan_interval(),
65            min_score: default_min_score(),
66            github_token: None,
67            output_dir: default_output_dir(),
68        }
69    }
70}
71
72impl std::fmt::Debug for SkillForgeConfig {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("SkillForgeConfig")
75            .field("enabled", &self.enabled)
76            .field("auto_integrate", &self.auto_integrate)
77            .field("sources", &self.sources)
78            .field("scan_interval_hours", &self.scan_interval_hours)
79            .field("min_score", &self.min_score)
80            .field("github_token", &self.github_token.as_ref().map(|_| "***"))
81            .field("output_dir", &self.output_dir)
82            .finish()
83    }
84}
85
86// ---------------------------------------------------------------------------
87// ForgeReport — summary of a single pipeline run
88// ---------------------------------------------------------------------------
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ForgeReport {
92    pub discovered: usize,
93    pub evaluated: usize,
94    pub auto_integrated: usize,
95    pub manual_review: usize,
96    pub skipped: usize,
97    pub results: Vec<EvalResult>,
98}
99
100// ---------------------------------------------------------------------------
101// SkillForge
102// ---------------------------------------------------------------------------
103
104pub struct SkillForge {
105    config: SkillForgeConfig,
106    evaluator: Evaluator,
107    integrator: Integrator,
108}
109
110impl SkillForge {
111    pub fn new(config: SkillForgeConfig) -> Self {
112        let evaluator = Evaluator::new(config.min_score);
113        let integrator = Integrator::new(config.output_dir.clone());
114        Self {
115            config,
116            evaluator,
117            integrator,
118        }
119    }
120
121    /// Run the full pipeline: Scout → Evaluate → Integrate.
122    pub async fn forge(&self) -> Result<ForgeReport> {
123        if !self.config.enabled {
124            ::zeroclaw_log::record!(
125                WARN,
126                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
127                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
128                "SkillForge is disabled — skipping"
129            );
130            return Ok(ForgeReport {
131                discovered: 0,
132                evaluated: 0,
133                auto_integrated: 0,
134                manual_review: 0,
135                skipped: 0,
136                results: vec![],
137            });
138        }
139
140        // --- Scout ----------------------------------------------------------
141        let mut candidates: Vec<ScoutResult> = Vec::new();
142
143        for src in &self.config.sources {
144            let source: ScoutSource = src.parse().unwrap(); // Infallible
145            match source {
146                ScoutSource::GitHub => {
147                    let scout = GitHubScout::new(self.config.github_token.clone());
148                    match scout.discover().await {
149                        Ok(mut found) => {
150                            ::zeroclaw_log::record!(
151                                INFO,
152                                ::zeroclaw_log::Event::new(
153                                    module_path!(),
154                                    ::zeroclaw_log::Action::Note
155                                )
156                                .with_attrs(::serde_json::json!({"count": found.len()})),
157                                "GitHub scout returned candidates"
158                            );
159                            candidates.append(&mut found);
160                        }
161                        Err(e) => {
162                            ::zeroclaw_log::record!(
163                                WARN,
164                                ::zeroclaw_log::Event::new(
165                                    module_path!(),
166                                    ::zeroclaw_log::Action::Note
167                                )
168                                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
169                                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
170                                "GitHub scout failed, continuing with other sources"
171                            );
172                        }
173                    }
174                }
175                ScoutSource::ClawHub | ScoutSource::HuggingFace => {
176                    ::zeroclaw_log::record!(
177                        INFO,
178                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
179                            .with_attrs(::serde_json::json!({"source": src.as_str()})),
180                        "Source not yet implemented — skipping"
181                    );
182                }
183            }
184        }
185
186        // Deduplicate by URL
187        scout::dedup(&mut candidates);
188        let discovered = candidates.len();
189        ::zeroclaw_log::record!(
190            INFO,
191            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
192                .with_attrs(::serde_json::json!({"discovered": discovered})),
193            "Total unique candidates after dedup"
194        );
195
196        // --- Evaluate -------------------------------------------------------
197        let results: Vec<EvalResult> = candidates
198            .into_iter()
199            .map(|c| self.evaluator.evaluate(c))
200            .collect();
201        let evaluated = results.len();
202
203        // --- Integrate ------------------------------------------------------
204        let mut auto_integrated = 0usize;
205        let mut manual_review = 0usize;
206        let mut skipped = 0usize;
207
208        for res in &results {
209            match res.recommendation {
210                Recommendation::Auto => {
211                    if self.config.auto_integrate {
212                        match self.integrator.integrate(&res.candidate) {
213                            Ok(_) => {
214                                auto_integrated += 1;
215                            }
216                            Err(e) => {
217                                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"skill": res.candidate.name.as_str(), "error": format!("{}", e)})), "Integration failed for candidate, continuing");
218                            }
219                        }
220                    } else {
221                        // Count as would-be auto but not actually integrated
222                        manual_review += 1;
223                    }
224                }
225                Recommendation::Manual => {
226                    manual_review += 1;
227                }
228                Recommendation::Skip => {
229                    skipped += 1;
230                }
231            }
232        }
233
234        ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"auto_integrated": auto_integrated, "manual_review": manual_review, "skipped": skipped})), "Forge pipeline complete");
235
236        Ok(ForgeReport {
237            discovered,
238            evaluated,
239            auto_integrated,
240            manual_review,
241            skipped,
242            results,
243        })
244    }
245}
246
247// ---------------------------------------------------------------------------
248// Tests
249// ---------------------------------------------------------------------------
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[tokio::test]
256    async fn disabled_forge_returns_empty_report() {
257        let cfg = SkillForgeConfig {
258            enabled: false,
259            ..Default::default()
260        };
261        let forge = SkillForge::new(cfg);
262        let report = forge.forge().await.unwrap();
263        assert_eq!(report.discovered, 0);
264        assert_eq!(report.auto_integrated, 0);
265    }
266
267    #[test]
268    fn default_config_values() {
269        let cfg = SkillForgeConfig::default();
270        assert!(!cfg.enabled);
271        assert!(cfg.auto_integrate);
272        assert_eq!(cfg.scan_interval_hours, 24);
273        assert!((cfg.min_score - 0.7).abs() < f64::EPSILON);
274        assert_eq!(cfg.sources, vec!["github", "clawhub"]);
275    }
276}