decapod 0.38.12

Decapod is the daemonless, local-first control plane that agents call on demand to align intent, enforce boundaries, and produce proof-backed completion across concurrent multi-agent work. 🦀
Documentation
use crate::core::{assets, docs, error};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct ContextCapsuleSource {
    pub path: String,
    pub section: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct ContextCapsuleSnippet {
    pub source_path: String,
    pub text: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeterministicContextCapsule {
    pub topic: String,
    pub scope: String,
    pub task_id: Option<String>,
    pub workunit_id: Option<String>,
    pub sources: Vec<ContextCapsuleSource>,
    pub snippets: Vec<ContextCapsuleSnippet>,
    pub capsule_hash: String,
}

impl DeterministicContextCapsule {
    fn canonicalized_without_hash(&self) -> CanonicalCapsule {
        let mut sources = self.sources.clone();
        sources.sort();
        sources.dedup();

        let mut snippets = self.snippets.clone();
        snippets.sort();
        snippets.dedup();

        CanonicalCapsule {
            topic: self.topic.clone(),
            scope: self.scope.clone(),
            task_id: self.task_id.clone(),
            workunit_id: self.workunit_id.clone(),
            sources,
            snippets,
        }
    }

    pub fn canonical_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
        serde_json::to_vec(&self.canonicalized_without_hash())
    }

    pub fn computed_hash_hex(&self) -> Result<String, serde_json::Error> {
        let bytes = self.canonical_json_bytes()?;
        let mut hasher = Sha256::new();
        hasher.update(&bytes);
        Ok(format!("{:x}", hasher.finalize()))
    }

    pub fn with_recomputed_hash(&self) -> Result<Self, serde_json::Error> {
        let mut out = self.clone();
        out.capsule_hash = out.computed_hash_hex()?;
        Ok(out)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct CanonicalCapsule {
    topic: String,
    scope: String,
    task_id: Option<String>,
    workunit_id: Option<String>,
    sources: Vec<ContextCapsuleSource>,
    snippets: Vec<ContextCapsuleSnippet>,
}

pub fn query_embedded_capsule(
    repo_root: &Path,
    topic: &str,
    scope: &str,
    task_id: Option<&str>,
    workunit_id: Option<&str>,
    limit: usize,
) -> Result<DeterministicContextCapsule, error::DecapodError> {
    validate_scope(scope)?;
    if topic.trim().is_empty() {
        return Err(error::DecapodError::ValidationError(
            "topic cannot be empty".to_string(),
        ));
    }
    let max = limit.max(1);
    let scope_prefix = format!("{}/", scope);

    let mut fragments = docs::resolve_scoped_fragments(
        repo_root,
        Some(topic),
        None,
        &[],
        &[],
        max.saturating_mul(3),
    )
    .into_iter()
    .filter(|f| f.r#ref.starts_with(&scope_prefix))
    .collect::<Vec<_>>();

    if fragments.is_empty() {
        let mut paths = assets::list_docs()
            .into_iter()
            .filter(|p| p.starts_with(&scope_prefix))
            .collect::<Vec<_>>();
        paths.sort();
        for path in paths.into_iter().take(max) {
            if let Some(fragment) = docs::get_fragment(repo_root, &path, None) {
                fragments.push(fragment);
            }
        }
    }

    fragments.truncate(max);

    let mut sources = Vec::new();
    let mut snippets = Vec::new();
    for fragment in fragments {
        let source_path = fragment
            .r#ref
            .split('#')
            .next()
            .unwrap_or(fragment.r#ref.as_str())
            .to_string();
        sources.push(ContextCapsuleSource {
            path: source_path.clone(),
            section: fragment.title.clone(),
        });
        snippets.push(ContextCapsuleSnippet {
            source_path,
            text: fragment.excerpt.trim().to_string(),
        });
    }

    let capsule = DeterministicContextCapsule {
        topic: topic.to_string(),
        scope: scope.to_string(),
        task_id: task_id.map(str::to_string),
        workunit_id: workunit_id.map(str::to_string),
        sources,
        snippets,
        capsule_hash: String::new(),
    };

    capsule.with_recomputed_hash().map_err(|e| {
        error::DecapodError::ValidationError(format!(
            "failed to canonicalize context capsule: {}",
            e
        ))
    })
}

fn validate_scope(scope: &str) -> Result<(), error::DecapodError> {
    match scope {
        "core" | "interfaces" | "plugins" => Ok(()),
        _ => Err(error::DecapodError::ValidationError(format!(
            "invalid scope '{}': expected one of core|interfaces|plugins",
            scope
        ))),
    }
}

pub fn context_capsules_dir(project_root: &Path) -> PathBuf {
    project_root
        .join(".decapod")
        .join("generated")
        .join("context")
}

pub fn context_capsule_path(project_root: &Path, capsule: &DeterministicContextCapsule) -> PathBuf {
    let file_stem = if let Some(workunit_id) = capsule.workunit_id.as_ref() {
        workunit_id.clone()
    } else if let Some(task_id) = capsule.task_id.as_ref() {
        task_id.clone()
    } else {
        let input = format!("{}::{}", capsule.scope, capsule.topic);
        let mut hasher = Sha256::new();
        hasher.update(input.as_bytes());
        let digest = format!("{:x}", hasher.finalize());
        format!("{}-{}", capsule.scope, &digest[..12])
    };
    context_capsules_dir(project_root).join(format!("{file_stem}.json"))
}

pub fn write_context_capsule(
    project_root: &Path,
    capsule: &DeterministicContextCapsule,
) -> Result<PathBuf, error::DecapodError> {
    let normalized = capsule.with_recomputed_hash().map_err(|e| {
        error::DecapodError::ValidationError(format!(
            "failed to canonicalize context capsule: {}",
            e
        ))
    })?;
    let path = context_capsule_path(project_root, &normalized);
    let parent = path.parent().ok_or_else(|| {
        error::DecapodError::ValidationError("invalid context capsule parent path".to_string())
    })?;
    fs::create_dir_all(parent).map_err(error::DecapodError::IoError)?;
    let bytes = serde_json::to_vec_pretty(&normalized).map_err(|e| {
        error::DecapodError::ValidationError(format!("failed to serialize context capsule: {}", e))
    })?;
    fs::write(&path, bytes).map_err(error::DecapodError::IoError)?;
    Ok(path)
}