navi-core 0.3.1

Local agentic engine and terminal-first coding agent.
Documentation
//! Trace-driven skill mining.

use crate::trace::{TurnOutcome, TurnTrace};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillDraft {
    pub name: String,
    pub trigger: String,
    pub workflow: Vec<String>,
    pub required_tools: Vec<String>,
    pub required_capabilities: Vec<String>,
    pub verification_steps: Vec<String>,
    pub examples: Vec<String>,
    pub activated: bool,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SkillReplayReport {
    pub passed: bool,
    pub replay_pass_rate: f64,
    pub reason: String,
}

pub fn draft_skill_from_traces(traces: &[TurnTrace], min_repetitions: usize) -> Option<SkillDraft> {
    if traces.len() < min_repetitions {
        return None;
    }
    let successful = traces
        .iter()
        .filter(|trace| {
            matches!(
                trace.outcome,
                TurnOutcome::Success | TurnOutcome::PartialSuccess
            )
        })
        .collect::<Vec<_>>();
    if successful.len() < min_repetitions {
        return None;
    }
    let first_tools = successful
        .first()?
        .tool_calls
        .iter()
        .map(|call| call.invocation.tool_name.clone())
        .collect::<Vec<_>>();
    if first_tools.is_empty() {
        return None;
    }
    let repeated = successful.iter().all(|trace| {
        trace
            .tool_calls
            .iter()
            .map(|call| call.invocation.tool_name.clone())
            .collect::<Vec<_>>()
            == first_tools
    });
    if !repeated {
        return None;
    }

    let verification_steps = successful
        .iter()
        .flat_map(|trace| {
            trace
                .verifier_results
                .iter()
                .filter(|verifier| verifier.passed)
        })
        .map(|verifier| verifier.command.clone())
        .collect::<std::collections::BTreeSet<_>>()
        .into_iter()
        .collect::<Vec<_>>();
    if verification_steps.is_empty() {
        return None;
    }
    let required_capabilities = successful
        .iter()
        .flat_map(|trace| trace.capabilities.iter())
        .map(|entry| entry.capability.as_key())
        .collect::<std::collections::BTreeSet<_>>()
        .into_iter()
        .collect::<Vec<_>>();

    Some(SkillDraft {
        name: sanitize_name(&successful[0].task),
        trigger: successful[0].task.clone(),
        workflow: first_tools.clone(),
        required_tools: first_tools,
        required_capabilities,
        verification_steps,
        examples: successful.iter().map(|trace| trace.task.clone()).collect(),
        activated: false,
    })
}

pub fn activate_skill_after_replay(
    mut draft: SkillDraft,
    report: &SkillReplayReport,
    threshold: f64,
) -> SkillDraft {
    draft.activated = report.passed && report.replay_pass_rate >= threshold;
    draft
}

fn sanitize_name(value: &str) -> String {
    let mut out = String::new();
    for ch in value.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
        } else if !out.ends_with('-') {
            out.push('-');
        }
    }
    out.trim_matches('-').chars().take(64).collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tool::{ToolInvocation, ToolResult};

    fn trace(id: &str) -> TurnTrace {
        let mut trace = TurnTrace::new(id, "s", "p", "m", "Fix repeated task");
        trace.record_capability(crate::capability::CapabilityLedgerEntry {
            capability: crate::capability::Capability::RepoRead,
            scope: crate::capability::CapabilityScope::Turn(id.to_string()),
            decision: crate::capability::CapabilityDecision::Consumed,
            at_ms: 1,
            justification: "read source".to_string(),
        });
        trace.record_tool_call(
            &ToolInvocation {
                id: "read".to_string(),
                tool_name: "read_file".to_string(),
                input: serde_json::json!({"path": "src/lib.rs"}),
            },
            &ToolResult {
                invocation_id: "read".to_string(),
                ok: true,
                output: serde_json::json!({}),
            },
            1,
        );
        trace.record_verifier("test", "just test-crate navi-core", true, 1, Some(0));
        trace
    }

    #[test]
    fn repeated_successful_traces_create_skill_draft() {
        let draft = draft_skill_from_traces(&[trace("a"), trace("b")], 2).unwrap();

        assert_eq!(draft.required_tools, vec!["read_file"]);
        assert_eq!(draft.required_capabilities, vec!["repo.read"]);
        assert!(!draft.activated);
    }

    #[test]
    fn bad_replay_does_not_activate_skill() {
        let draft = draft_skill_from_traces(&[trace("a"), trace("b")], 2).unwrap();
        let activated = activate_skill_after_replay(
            draft,
            &SkillReplayReport {
                passed: false,
                replay_pass_rate: 0.0,
                reason: "failed".to_string(),
            },
            0.8,
        );

        assert!(!activated.activated);
    }
}