bevy_grimoire_core 0.4.0

Engine-free world-building core: constraint IR, solvers, WFC, deterministic RNG.
Documentation
//! `agent.ron`: the command Send starts for the notes (#94).
//!
//! A project names a coding agent here, and pressing Send in the editor starts it once, so a person
//! with no agent running still gets their notes worked. The editor holds no agent logic: it starts a
//! process, which connects over MCP like any hand-started agent. With no file, or no command, Send only
//! queues the notes. Engine-free: `std::process` only.

use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

pub const AGENT_VERSION: u32 = 1;

/// The model an agent is started with when the file does not name one.
pub const DEFAULT_MODEL: &str = "claude-sonnet-5-5";

/// What the started agent is asked. It names the guide, so the prompt stays short and the rules live in one place.
pub const DEFAULT_PROMPT: &str = "Work the sent notes on the open map through the editor's MCP server. \
Read the notes guide in the MCP docs first: call grimoire_state, claim a sent note with note_claim, stay inside its area, \
ask before removing a person's piece, and finish with note_resolve.";

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentSettings {
    pub version: u32,
    /// The program to start. `None` means Send only queues the notes.
    #[serde(default)]
    pub command: Option<String>,
    /// Its arguments. `{model}`, `{prompt}` and `{project}` are replaced. Empty means `-p --model {model} {prompt}`, which is `omp`'s headless form.
    #[serde(default)]
    pub args: Vec<String>,
    #[serde(default = "default_model")]
    pub model: String,
}

fn default_model() -> String {
    DEFAULT_MODEL.to_owned()
}

impl Default for AgentSettings {
    fn default() -> Self {
        AgentSettings { version: AGENT_VERSION, command: None, args: Vec::new(), model: default_model() }
    }
}

/// `<project>/agent.ron`.
pub fn path_for_project(root: &Path) -> PathBuf {
    root.join("agent.ron")
}

impl AgentSettings {
    pub fn from_ron(text: &str) -> Result<AgentSettings, String> {
        let s: AgentSettings = ron::from_str(text).map_err(|e| format!("agent: parse: {e}"))?;
        if s.version != AGENT_VERSION {
            return Err(format!("agent: version {} is not the {AGENT_VERSION} this build reads", s.version));
        }
        if s.command.as_deref().is_some_and(|c| c.trim().is_empty()) {
            return Err("agent: `command` is empty; write a program or remove the line".to_owned());
        }
        Ok(s)
    }

    /// Reads the project's `agent.ron`; a project without one launches nothing.
    pub fn load(root: &Path) -> Result<AgentSettings, String> {
        let path = path_for_project(root);
        match std::fs::read_to_string(&path) {
            Ok(text) => AgentSettings::from_ron(&text).map_err(|e| format!("{}: {e}", path.display())),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AgentSettings::default()),
            Err(e) => Err(format!("agent: {}: {e}", path.display())),
        }
    }

    /// The arguments with `{model}`, `{prompt}` and `{project}` filled in.
    pub fn argv(&self, root: &Path) -> Vec<String> {
        let template: Vec<String> = if self.args.is_empty() {
            ["-p", "--model", "{model}", "{prompt}"].map(str::to_owned).to_vec()
        } else {
            self.args.clone()
        };
        let project = root.to_string_lossy();
        template
            .iter()
            .map(|a| a.replace("{model}", &self.model).replace("{prompt}", DEFAULT_PROMPT).replace("{project}", &project))
            .collect()
    }

    /// **Start the agent, once.** `Ok(None)` when no command is set. The child runs in the project folder
    /// with `BEVY_GRIMOIRE_PROJECT` set; a thread reaps it when it ends. A program that cannot start is an
    /// `Err` naming it, so the editor reports it rather than dropping the send silently.
    pub fn launch(&self, root: &Path) -> Result<Option<u32>, String> {
        let Some(program) = &self.command else { return Ok(None) };
        let mut child = Command::new(program)
            .args(self.argv(root))
            .current_dir(root)
            .env("BEVY_GRIMOIRE_PROJECT", root)
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .map_err(|e| format!("could not start the notes agent `{program}`: {e}"))?;
        let pid = child.id();
        std::thread::spawn(move || {
            let _ = child.wait();
        });
        Ok(Some(pid))
    }
}

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

    #[test]
    fn a_file_with_no_command_is_the_default_and_a_blank_command_is_refused() {
        let s = AgentSettings::from_ron("(version: 1)").unwrap();
        assert_eq!(s, AgentSettings::default());
        assert!(AgentSettings::from_ron("(version: 1, command: Some(\" \"))").is_err());
        assert!(AgentSettings::from_ron("(version: 2)").is_err());
    }

    #[test]
    fn placeholders_are_filled_and_the_default_form_is_omps() {
        let s = AgentSettings::from_ron("(version: 1, command: Some(\"omp\"), model: \"m1\")").unwrap();
        let argv = s.argv(Path::new("/p"));
        assert_eq!(&argv[..3], ["-p", "--model", "m1"]);
        assert!(argv[3].contains("note_resolve"));
        let s = AgentSettings::from_ron("(version: 1, command: Some(\"x\"), args: [\"{project}\"])").unwrap();
        assert_eq!(s.argv(Path::new("/p")), ["/p"]);
    }
}