agent-scroll 0.1.0

Canonical byte-deterministic transcript format for AI-agent conversations (Rust port of @p-vbordei/agent-scroll)
Documentation
use base64::{engine::general_purpose::STANDARD, Engine};
use ed25519_dalek::{Signer, SigningKey};
use serde_json::{json, Map, Value};

use crate::canonical::{canonical, hash_canonical};
use crate::error::Error;

#[derive(Clone)]
pub struct SignOpts {
    pub privkey: [u8; 32],
    pub pubkey: [u8; 32],
}

pub fn seal(turn: &Value, sign: Option<&SignOpts>) -> Result<Value, Error> {
    let h = hash_canonical(turn)?;
    let mut out = turn
        .as_object()
        .cloned()
        .ok_or_else(|| Error::Invalid("turn not object".into()))?;
    out.insert("hash".into(), json!(h));
    if let Some(s) = sign {
        let sk = SigningKey::from_bytes(&s.privkey);
        let canonical_bytes = canonical(turn)?;
        let sig = sk.sign(&canonical_bytes);
        out.insert(
            "sig".into(),
            json!({
                "alg": "ed25519",
                "pubkey": STANDARD.encode(s.pubkey),
                "sig": STANDARD.encode(sig.to_bytes()),
            }),
        );
    }
    Ok(Value::Object(out))
}

pub fn seal_chain(turns: &[Value], sign: Option<&SignOpts>) -> Result<Vec<Value>, Error> {
    let mut out = Vec::with_capacity(turns.len());
    let mut prev: Option<String> = None;
    for t in turns {
        let mut linked: Map<String, Value> = t
            .as_object()
            .cloned()
            .ok_or_else(|| Error::Invalid("turn not object".into()))?;
        if let Some(p) = &prev {
            if !linked.contains_key("prev_hash") {
                linked.insert("prev_hash".into(), json!(p));
            }
        }
        let linked_v = Value::Object(linked);
        let sealed = seal(&linked_v, sign)?;
        prev = sealed
            .get("hash")
            .and_then(|v| v.as_str())
            .map(String::from);
        out.push(sealed);
    }
    Ok(out)
}