procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
//! Where the agent's knowledge came from, and how to pin it.
//!
//! Skills are markdown files on disk, in three directories any of which the user may edit, and one
//! of which (`~/.claude/skills`) is shared with another tool that installs and updates packs there.
//! MCP servers are remote and answer differently over time. So "the agent said X because a skill
//! told it to" was, until now, an unanswerable claim: nothing recorded which file was loaded, what
//! it contained at the time, or which endpoint answered.
//!
//! Two things fix that, and they are different things. **Provenance** travels with a skill the
//! moment it is loaded, so the transcript and the session log say what was read. **A snapshot** is
//! the whole knowledge surface written down at one instant, so a run can be reproduced or a
//! difference in behaviour can be traced to a difference in inputs rather than to the model.
//!
//! The digest is `sha256`, truncated for reading. A truncated hash is not a security boundary and
//! is not meant to be one: the question it answers is "is this the same text as last time", where
//! the adversary is an editor and an update script, not a forger.

use std::path::PathBuf;

use sha2::{Digest, Sha256};

/// How many hex characters of the digest to keep.
///
/// Twelve is enough that two skills in one snapshot will not collide by accident, and short enough
/// to sit on a line the user actually reads.
const DIGEST_CHARS: usize = 12;

/// Hashes content for identification. Prefixed with the algorithm so a later change of algorithm
/// cannot be mistaken for a change of content.
pub fn digest(content: &str) -> String {
    let hash = Sha256::digest(content.as_bytes());
    let hex: String = hash.iter().map(|byte| format!("{:02x}", byte)).collect();
    format!("sha256:{}", &hex[..DIGEST_CHARS])
}

/// Which file a piece of knowledge came from, and what it said.
#[derive(Debug, Clone, PartialEq)]
pub struct Provenance {
    pub name: String,
    /// From the skill's own frontmatter. `None` when the author did not state one — recorded as
    /// absent rather than defaulted to `0.1.0`, because a version nobody wrote is not a version.
    pub version: Option<String>,
    pub digest: String,
    pub origin: PathBuf,
}

impl Provenance {
    /// The one-line form for a transcript or a tool result.
    pub fn cite(&self) -> String {
        match &self.version {
            Some(version) => format!("{} v{} ({})", self.name, version, self.digest),
            None => format!("{} ({})", self.name, self.digest),
        }
    }
}

/// The whole knowledge surface at one instant.
#[derive(Debug, Clone, Default)]
pub struct Snapshot {
    /// The Procyon that took it. Part of the record: the working rules and the tool schemas are
    /// knowledge too, and they ship with the binary.
    pub procyon: String,
    pub skills: Vec<Provenance>,
    /// Name and endpoint per configured server. Not their tool lists — those are the server's to
    /// change, and recording them here would read as a promise this cannot keep.
    pub mcp_servers: Vec<(String, String)>,
}

impl Snapshot {
    /// TOML, so it can be committed next to a project and diffed by a human or by CI.
    pub fn to_toml(&self) -> String {
        let mut out = format!("procyon = \"{}\"\n", self.procyon);

        if self.skills.is_empty() {
            out.push_str("\n# No skills were discovered.\n");
        }
        for skill in &self.skills {
            out.push_str("\n[[skill]]\n");
            out.push_str(&format!("name = \"{}\"\n", skill.name));
            if let Some(version) = &skill.version {
                out.push_str(&format!("version = \"{}\"\n", version));
            }
            out.push_str(&format!("digest = \"{}\"\n", skill.digest));
            out.push_str(&format!("origin = \"{}\"\n", skill.origin.display()));
        }

        for (name, endpoint) in &self.mcp_servers {
            out.push_str("\n[[mcp_server]]\n");
            out.push_str(&format!("name = \"{}\"\n", name));
            out.push_str(&format!("endpoint = \"{}\"\n", endpoint));
        }

        out
    }
}

/// Takes the snapshot.
///
/// Reads the same registry `run_skill` resolves against, so what this records is what a turn would
/// actually load — a snapshot assembled by walking the directories itself could disagree with the
/// process it claims to describe.
pub async fn snapshot(cfg: &crate::config::AppConfig) -> Snapshot {
    let skills = crate::registries::skills().await;

    // Sorted, because discovery order is `read_dir` order and therefore the filesystem's. An
    // unsorted snapshot re-shuffles between runs, and a file that diffs when nothing changed is a
    // file people stop reading the diffs of.
    let mut provenances: Vec<Provenance> = skills.all().iter().map(|s| s.provenance()).collect();
    provenances.sort_by(|a, b| a.name.cmp(&b.name));

    Snapshot {
        procyon: env!("CARGO_PKG_VERSION").to_string(),
        skills: provenances,
        mcp_servers: cfg
            .mcp_servers
            .iter()
            .map(|server| (server.name.clone(), server.endpoint_label()))
            .collect(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_digest_names_its_algorithm_and_is_stable() {
        let first = digest("# Soroban\n\nStorage has three durabilities.");
        assert_eq!(
            first,
            digest("# Soroban\n\nStorage has three durabilities.")
        );
        assert!(first.starts_with("sha256:"), "got {}", first);
    }

    #[test]
    fn different_content_digests_differently() {
        assert_ne!(digest("a"), digest("b"));
        // Including a change small enough to miss by eye, which is the case this exists for.
        assert_ne!(digest("use testnet"), digest("use mainnet"));
    }

    #[test]
    fn a_citation_carries_the_version_when_the_author_stated_one() {
        let provenance = Provenance {
            name: "soroban".to_string(),
            version: Some("2.1.0".to_string()),
            digest: "sha256:abc123abc123".to_string(),
            origin: PathBuf::from("/skills/soroban"),
        };
        assert_eq!(provenance.cite(), "soroban v2.1.0 (sha256:abc123abc123)");
    }

    // A version nobody wrote is not a version, and inventing one would make two different skills
    // look like the same one at different points in time.
    #[test]
    fn a_citation_omits_a_version_nobody_declared() {
        let provenance = Provenance {
            name: "soroban".to_string(),
            version: None,
            digest: "sha256:abc123abc123".to_string(),
            origin: PathBuf::from("/skills/soroban"),
        };
        assert_eq!(provenance.cite(), "soroban (sha256:abc123abc123)");
    }

    #[test]
    fn a_snapshot_records_what_was_loaded_and_where_from() {
        let snapshot = Snapshot {
            procyon: "0.1.0".to_string(),
            skills: vec![Provenance {
                name: "soroban".to_string(),
                version: Some("2.1.0".to_string()),
                digest: "sha256:abc123abc123".to_string(),
                origin: PathBuf::from("/skills/soroban"),
            }],
            mcp_servers: vec![("raven".to_string(), "https://raven/mcp".to_string())],
        };

        let toml = snapshot.to_toml();
        for expected in [
            "procyon = \"0.1.0\"",
            "name = \"soroban\"",
            "version = \"2.1.0\"",
            "digest = \"sha256:abc123abc123\"",
            "origin = \"/skills/soroban\"",
            "endpoint = \"https://raven/mcp\"",
        ] {
            assert!(
                toml.contains(expected),
                "{:?} missing from\n{}",
                expected,
                toml
            );
        }
    }

    // It has to parse, or "commit it and diff it" is not a workflow.
    #[test]
    fn a_snapshot_is_valid_toml() {
        let snapshot = Snapshot {
            procyon: "0.1.0".to_string(),
            skills: vec![Provenance {
                name: "soroban".to_string(),
                version: None,
                digest: digest("body"),
                origin: PathBuf::from("/skills/soroban"),
            }],
            mcp_servers: vec![("raven".to_string(), "https://raven/mcp".to_string())],
        };

        let parsed: toml::Value = snapshot.to_toml().parse().expect("snapshot must parse");
        assert_eq!(parsed["skill"][0]["name"].as_str(), Some("soroban"));
        assert!(parsed["skill"][0].get("version").is_none());
        assert_eq!(parsed["mcp_server"][0]["name"].as_str(), Some("raven"));
    }

    #[test]
    fn an_empty_snapshot_still_parses_and_says_so() {
        let toml = Snapshot::default().to_toml();
        assert!(toml.contains("No skills were discovered"));
        toml.parse::<toml::Value>().expect("must parse");
    }
}