collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! AgentWorkspace — typed read/write access to an evolution workspace directory.
//!
//! The workspace follows A-Evolve's filesystem contract:
//!
//! ```text
//! workspace/
//! ├── prompts/system.md
//! ├── prompts/fragments/
//! ├── skills/*/SKILL.md
//! ├── skills/_drafts/
//! ├── tools/registry.yaml
//! ├── memory/*.jsonl
//! └── evolution/
//! ```
//!
//! Reuses Collet's existing YAML frontmatter parser from `skills::discovery`.

use std::io::Write;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use regex::Regex;
use std::sync::LazyLock;

use super::types::EvoSkillMeta;

static FRONTMATTER_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"(?s)\A---\s*\n(.*?)\n---").unwrap());

/// Typed read/write access to an agent workspace following the FS contract.
///
/// This is the primary interface used by both the agent (to load state) and
/// the evolution engine (to mutate state).
pub struct AgentWorkspace {
    pub root: PathBuf,
    pub prompts_dir: PathBuf,
    pub skills_dir: PathBuf,
    pub drafts_dir: PathBuf,
    pub tools_dir: PathBuf,
    pub memory_dir: PathBuf,
    pub evolution_dir: PathBuf,
}

impl AgentWorkspace {
    pub fn new(root: impl AsRef<Path>) -> Self {
        let root = root.as_ref().to_path_buf();
        Self {
            prompts_dir: root.join("prompts"),
            skills_dir: root.join("skills"),
            drafts_dir: root.join("skills").join("_drafts"),
            tools_dir: root.join("tools"),
            memory_dir: root.join("memory"),
            evolution_dir: root.join("evolution"),
            root,
        }
    }

    /// Ensure all workspace directories exist.
    pub fn ensure_dirs(&self) -> Result<()> {
        for dir in [
            &self.prompts_dir,
            &self.skills_dir,
            &self.tools_dir,
            &self.memory_dir,
            &self.evolution_dir,
        ] {
            std::fs::create_dir_all(dir)
                .with_context(|| format!("Failed to create {}", dir.display()))?;
        }
        Ok(())
    }

    // -- Prompts ----------------------------------------------------------

    /// Read the system prompt.
    pub fn read_prompt(&self) -> Result<String> {
        let path = self.prompts_dir.join("system.md");
        if path.exists() {
            Ok(std::fs::read_to_string(&path)?)
        } else {
            Ok(String::new())
        }
    }

    /// Write the system prompt.
    pub fn write_prompt(&self, content: &str) -> Result<()> {
        std::fs::create_dir_all(&self.prompts_dir)?;
        std::fs::write(self.prompts_dir.join("system.md"), content)?;
        Ok(())
    }

    /// Read a named prompt fragment.
    pub fn read_fragment(&self, name: &str) -> Result<String> {
        let path = self.prompts_dir.join("fragments").join(name);
        if path.exists() {
            Ok(std::fs::read_to_string(&path)?)
        } else {
            Ok(String::new())
        }
    }

    /// Write a named prompt fragment.
    pub fn write_fragment(&self, name: &str, content: &str) -> Result<()> {
        let frag_dir = self.prompts_dir.join("fragments");
        std::fs::create_dir_all(&frag_dir)?;
        std::fs::write(frag_dir.join(name), content)?;
        Ok(())
    }

    /// List all prompt fragment names.
    pub fn list_fragments(&self) -> Result<Vec<String>> {
        let frag_dir = self.prompts_dir.join("fragments");
        if !frag_dir.exists() {
            return Ok(Vec::new());
        }
        let mut names: Vec<String> = std::fs::read_dir(&frag_dir)?
            .filter_map(|e| e.ok())
            .filter(|e| e.path().is_file())
            .filter_map(|e| e.file_name().to_str().map(String::from))
            .collect();
        names.sort();
        Ok(names)
    }

    // -- Skills -----------------------------------------------------------

    /// List all skills in the workspace (excluding drafts).
    pub fn list_skills(&self) -> Vec<EvoSkillMeta> {
        if !self.skills_dir.exists() {
            return Vec::new();
        }

        let mut skills = Vec::new();
        let mut entries: Vec<_> = std::fs::read_dir(&self.skills_dir)
            .into_iter()
            .flatten()
            .flatten()
            .filter(|e| e.path().is_dir())
            .filter(|e| {
                !e.file_name()
                    .to_str()
                    .map(|n| n.starts_with('_'))
                    .unwrap_or(true)
            })
            .collect();
        entries.sort_by_key(|e| e.file_name());

        for entry in entries {
            let skill_file = entry.path().join("SKILL.md");
            if skill_file.exists() {
                let mut meta = parse_skill_frontmatter(&skill_file);
                if let Ok(rel) = entry.path().strip_prefix(&self.root) {
                    meta.path = rel.display().to_string();
                }
                skills.push(meta);
            }
        }

        skills
    }

    /// Read a skill's SKILL.md content.
    pub fn read_skill(&self, name: &str) -> Result<String> {
        let path = self.skills_dir.join(name).join("SKILL.md");
        if path.exists() {
            Ok(std::fs::read_to_string(&path)?)
        } else {
            Ok(String::new())
        }
    }

    /// Write (create or overwrite) a skill.
    pub fn write_skill(&self, name: &str, content: &str) -> Result<()> {
        let skill_dir = self.skills_dir.join(name);
        std::fs::create_dir_all(&skill_dir)?;
        std::fs::write(skill_dir.join("SKILL.md"), content)?;
        Ok(())
    }

    /// Delete a skill directory.
    pub fn delete_skill(&self, name: &str) -> Result<()> {
        let skill_dir = self.skills_dir.join(name);
        if skill_dir.exists() {
            std::fs::remove_dir_all(&skill_dir)?;
        }
        Ok(())
    }

    // -- Drafts -----------------------------------------------------------

    /// List skill drafts in `skills/_drafts/`.
    pub fn list_drafts(&self) -> Vec<(String, String)> {
        if !self.drafts_dir.exists() {
            return Vec::new();
        }
        let mut drafts = Vec::new();
        if let Ok(entries) = std::fs::read_dir(&self.drafts_dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path.extension().and_then(|e| e.to_str()) == Some("md")
                    && let (Some(name), Ok(content)) = (
                        path.file_stem().and_then(|s| s.to_str()).map(String::from),
                        std::fs::read_to_string(&path),
                    )
                {
                    drafts.push((name, content));
                }
            }
        }
        drafts.sort_by(|a, b| a.0.cmp(&b.0));
        drafts
    }

    /// Write a draft skill.
    pub fn write_draft(&self, name: &str, content: &str) -> Result<()> {
        std::fs::create_dir_all(&self.drafts_dir)?;
        std::fs::write(self.drafts_dir.join(format!("{name}.md")), content)?;
        Ok(())
    }

    /// Remove all drafts.
    pub fn clear_drafts(&self) -> Result<()> {
        if self.drafts_dir.exists() {
            for entry in std::fs::read_dir(&self.drafts_dir)?.flatten() {
                let path = entry.path();
                if path.extension().and_then(|e| e.to_str()) == Some("md") {
                    std::fs::remove_file(path)?;
                }
            }
        }
        Ok(())
    }

    // -- Memory -----------------------------------------------------------

    /// Append an entry to a memory category (JSONL).
    pub fn add_memory(&self, entry: &serde_json::Value, category: &str) -> Result<()> {
        std::fs::create_dir_all(&self.memory_dir)?;
        let path = self.memory_dir.join(format!("{category}.jsonl"));
        let mut file = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(&path)?;
        serde_json::to_writer(&mut file, entry)?;
        writeln!(file)?;
        Ok(())
    }

    /// Read the most recent entries from a memory category.
    pub fn read_memories(&self, category: &str, limit: usize) -> Result<Vec<serde_json::Value>> {
        let path = self.memory_dir.join(format!("{category}.jsonl"));
        if !path.exists() {
            return Ok(Vec::new());
        }
        let content = std::fs::read_to_string(&path)?;
        let entries: Vec<serde_json::Value> = content
            .lines()
            .filter(|l| !l.trim().is_empty())
            .filter_map(|l| serde_json::from_str(l).ok())
            .collect();
        let start = entries.len().saturating_sub(limit);
        Ok(entries[start..].to_vec())
    }

    /// Read memories across all categories.
    pub fn read_all_memories(&self, limit: usize) -> Result<Vec<serde_json::Value>> {
        if !self.memory_dir.exists() {
            return Ok(Vec::new());
        }
        let mut all = Vec::new();
        let mut paths: Vec<_> = std::fs::read_dir(&self.memory_dir)?
            .filter_map(|e| e.ok())
            .filter(|e| e.path().extension().and_then(|ext| ext.to_str()) == Some("jsonl"))
            .map(|e| e.path())
            .collect();
        paths.sort();

        for path in paths {
            let category = path
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("unknown")
                .to_string();
            let content = std::fs::read_to_string(&path)?;
            for line in content.lines() {
                if !line.trim().is_empty()
                    && let Ok(mut entry) = serde_json::from_str::<serde_json::Value>(line)
                {
                    if let Some(obj) = entry.as_object_mut() {
                        obj.entry("_category")
                            .or_insert_with(|| serde_json::Value::String(category.clone()));
                    }
                    all.push(entry);
                }
            }
        }

        let start = all.len().saturating_sub(limit);
        Ok(all[start..].to_vec())
    }

    // -- Evolution metadata (read-only for agents) ------------------------

    /// Read the evolution history log.
    pub fn read_evolution_history(&self) -> Result<Vec<serde_json::Value>> {
        let path = self.evolution_dir.join("history.jsonl");
        if !path.exists() {
            return Ok(Vec::new());
        }
        let content = std::fs::read_to_string(&path)?;
        Ok(content
            .lines()
            .filter(|l| !l.trim().is_empty())
            .filter_map(|l| serde_json::from_str(l).ok())
            .collect())
    }

    /// Read evolution metrics summary.
    pub fn read_evolution_metrics(&self) -> Result<serde_json::Value> {
        let path = self.evolution_dir.join("metrics.json");
        if !path.exists() {
            return Ok(serde_json::json!({}));
        }
        Ok(serde_json::from_str(&std::fs::read_to_string(&path)?)?)
    }
}

// -- Helpers --------------------------------------------------------------

/// Parse name and description from SKILL.md YAML frontmatter.
fn parse_skill_frontmatter(path: &Path) -> EvoSkillMeta {
    let text = match std::fs::read_to_string(path) {
        Ok(t) => t,
        Err(_) => {
            return EvoSkillMeta {
                name: dir_name(path),
                description: String::new(),
                path: String::new(),
            };
        }
    };

    if let Some(captures) = FRONTMATTER_RE.captures(&text)
        && let Some(yaml_str) = captures.get(1)
    {
        // Parse YAML frontmatter manually (key: value per line)
        // to avoid depending on serde_yaml.
        let mut name_val: Option<String> = None;
        let mut desc_val: Option<String> = None;
        for line in yaml_str.as_str().lines() {
            let line = line.trim();
            if let Some(v) = line.strip_prefix("name:") {
                name_val = Some(v.trim().trim_matches('"').trim_matches('\'').to_string());
            } else if let Some(v) = line.strip_prefix("description:") {
                desc_val = Some(v.trim().trim_matches('"').trim_matches('\'').to_string());
            }
        }
        if name_val.is_some() || desc_val.is_some() {
            return EvoSkillMeta {
                name: name_val.unwrap_or_else(|| dir_name(path)),
                description: desc_val.unwrap_or_default(),
                path: String::new(),
            };
        }
    }

    EvoSkillMeta {
        name: dir_name(path),
        description: String::new(),
        path: String::new(),
    }
}

fn dir_name(path: &Path) -> String {
    path.parent()
        .and_then(|p| p.file_name())
        .and_then(|n| n.to_str())
        .map(String::from)
        .unwrap_or_default()
}