opencrabs 0.3.56

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
Documentation
//! Load Brain File Tool
//!
//! Loads a specific brain context file from `~/.opencrabs/` on demand.
//! Use this to fetch USER.md, MEMORY.md, AGENTS.md, etc. only when the
//! current request actually needs that context, rather than injecting all
//! files into every turn.

use super::error::Result;
use super::r#trait::{Tool, ToolCapability, ToolExecutionContext, ToolResult};
use async_trait::async_trait;
use serde_json::Value;

use crate::brain::prompt_builder::CONTEXTUAL_BRAIN_FILES;

pub struct LoadBrainFileTool;

#[async_trait]
impl Tool for LoadBrainFileTool {
    fn name(&self) -> &str {
        "load_brain_file"
    }

    fn description(&self) -> &str {
        "Load any .md file from your OpenCrabs home directory (see Known paths — profile-scoped). \
         Works with built-in files (USER.md, MEMORY.md, AGENTS.md, TOOLS.md, SECURITY.md) \
         and any custom .md files you have created in your workspace. \
         Pass name=\"all\" to load all .md files at once. \
         To edit or update brain files, use the `write_opencrabs_file` tool."
    }

    fn input_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "name": {
                    "type": "string",
                    "description": "Brain file to load, e.g. \"MEMORY.md\", \"USER.md\", \"AGENTS.md\", \"TOOLS.md\", \"SECURITY.md\". Use \"all\" to load all contextual files."
                }
            },
            "required": ["name"]
        })
    }

    fn capabilities(&self) -> Vec<ToolCapability> {
        vec![ToolCapability::ReadFiles]
    }

    fn requires_approval(&self) -> bool {
        false
    }

    async fn execute(&self, input: Value, ctx: &ToolExecutionContext) -> Result<ToolResult> {
        let name = input
            .get("name")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .trim();

        if name.is_empty() {
            return Ok(ToolResult::error("name parameter is required".to_string()));
        }

        let home = crate::config::opencrabs_home();

        // Read-time empty-section stripping. Default on; opt out via
        // `[brain] strip_empty_sections = false` in config.toml.
        // Disk stays authoritative — writes never run through this.
        let strip_enabled = crate::config::Config::current().brain.strip_empty_sections;
        let apply_filter = |raw: String| -> (String, Vec<String>) {
            if !strip_enabled {
                return (raw, Vec::new());
            }
            let res = crate::brain::filter::strip_empty_sections(&raw);
            (res.content, res.stripped_headers)
        };

        // Per-project brain overlay. If this session belongs to a project that
        // carries its own brain file, it loads ON TOP of the profile's file
        // (append, never replace) so a project can ADD context without ever
        // silently dropping a profile-level hard rule. `None` for sessions with
        // no project, CLI one-shots, or tests without a service_context.
        let project_overlay: Option<(String, std::path::PathBuf)> = match &ctx.service_context {
            Some(svc) => {
                crate::services::ProjectService::new(svc.clone())
                    .project_brain_dir(ctx.session_id)
                    .await
            }
            None => None,
        };
        // Format a project overlay section for `fname`, or `None` if no project,
        // no such overlay file, or it filters down to empty.
        let overlay_section = |fname: &str| -> Option<String> {
            let (pname, dir) = project_overlay.as_ref()?;
            let raw = std::fs::read_to_string(dir.join(fname)).ok()?;
            let filtered = if strip_enabled {
                crate::brain::filter::strip_empty_sections(&raw).content
            } else {
                raw
            };
            let trimmed = filtered.trim();
            if trimmed.is_empty() {
                return None;
            }
            Some(format!(
                "--- {} (project: {} overlay) ---\n{}\n\n",
                fname, pname, trimmed
            ))
        };

        if name == "all" {
            let mut out = String::new();
            let mut stripped_all: Vec<String> = Vec::new();
            let mut seen = std::collections::HashSet::new();

            // Known contextual files first (stable order)
            for (fname, label) in CONTEXTUAL_BRAIN_FILES {
                seen.insert(fname.to_lowercase());
                let path = home.join(fname);
                if let Ok(content) = std::fs::read_to_string(&path) {
                    let (filtered, stripped) = apply_filter(content);
                    let trimmed = filtered.trim();
                    if !trimmed.is_empty() {
                        out.push_str(&format!("--- {} ({}) ---\n{}\n\n", fname, label, trimmed));
                    }
                    for h in stripped {
                        stripped_all.push(format!("{}: {}", fname, h));
                    }
                }
                // Project overlay rides ON TOP, right after the profile file.
                if let Some(ov) = overlay_section(fname) {
                    out.push_str(&ov);
                }
            }

            // User-created .md files not in the known list
            if let Ok(entries) = std::fs::read_dir(&home) {
                let mut extras: Vec<_> = entries
                    .filter_map(|e| e.ok())
                    .filter(|e| {
                        let name = e.file_name().to_string_lossy().to_string();
                        name.ends_with(".md") && !seen.contains(&name.to_lowercase())
                    })
                    .collect();
                extras.sort_by_key(|e| e.file_name());
                for entry in extras {
                    let fname = entry.file_name().to_string_lossy().to_string();
                    if let Ok(content) = std::fs::read_to_string(entry.path()) {
                        let (filtered, stripped) = apply_filter(content);
                        let trimmed = filtered.trim();
                        if !trimmed.is_empty() {
                            out.push_str(&format!("--- {} (user) ---\n{}\n\n", fname, trimmed));
                        }
                        for h in stripped {
                            stripped_all.push(format!("{}: {}", fname, h));
                        }
                    }
                    // Project overlay rides ON TOP, right after the profile file.
                    if let Some(ov) = overlay_section(&fname) {
                        out.push_str(&ov);
                    }
                    seen.insert(fname.to_lowercase());
                }
            }

            // Project-only brain files (no profile counterpart) still ride ON TOP.
            if let Some((_, dir)) = &project_overlay
                && let Ok(entries) = std::fs::read_dir(dir)
            {
                let mut extras: Vec<_> = entries
                    .filter_map(|e| e.ok())
                    .filter(|e| {
                        let n = e.file_name().to_string_lossy().to_string();
                        n.ends_with(".md") && !seen.contains(&n.to_lowercase())
                    })
                    .collect();
                extras.sort_by_key(|e| e.file_name());
                for entry in extras {
                    let fname = entry.file_name().to_string_lossy().to_string();
                    if let Some(ov) = overlay_section(&fname) {
                        out.push_str(&ov);
                    }
                    seen.insert(fname.to_lowercase());
                }
            }

            if !stripped_all.is_empty() {
                tracing::info!(
                    "load_brain_file(all): stripped {} empty section(s) on read: {:?}",
                    stripped_all.len(),
                    stripped_all
                );
            }

            return if out.is_empty() {
                Ok(ToolResult::success("No brain files found.".to_string()))
            } else {
                Ok(ToolResult::success(out))
            };
        }

        // Validate filename: must be a simple .md name (no path traversal)
        if name.contains('/') || name.contains('\\') || name.contains("..") {
            return Ok(ToolResult::error(format!(
                "Invalid brain file name '{}'. Must be a simple filename with no path separators",
                name
            )));
        }

        // Use canonical casing from the known list if it matches, otherwise use as-is
        let canonical = CONTEXTUAL_BRAIN_FILES
            .iter()
            .find(|(n, _)| n.eq_ignore_ascii_case(name))
            .map(|(n, _)| *n)
            .unwrap_or(name);

        let path = home.join(canonical);
        let overlay = overlay_section(canonical);
        match std::fs::read_to_string(&path) {
            Ok(content) => {
                let (filtered, stripped) = apply_filter(content);
                if !stripped.is_empty() {
                    tracing::info!(
                        "load_brain_file({}): stripped {} empty section(s) on read: {:?}",
                        canonical,
                        stripped.len(),
                        stripped
                    );
                }
                let trimmed = filtered.trim();
                let mut out = if trimmed.is_empty() {
                    String::new()
                } else {
                    format!("--- {} ---\n{}", canonical, trimmed)
                };
                if let Some(ov) = overlay {
                    if !out.is_empty() {
                        out.push_str("\n\n");
                    }
                    out.push_str(ov.trim_end());
                }
                if out.is_empty() {
                    Ok(ToolResult::success(format!(
                        "{} exists but is empty.",
                        canonical
                    )))
                } else {
                    Ok(ToolResult::success(out))
                }
            }
            // Profile file missing — still surface a project overlay if one exists.
            Err(_) => match overlay {
                Some(ov) => Ok(ToolResult::success(ov.trim_end().to_string())),
                None => Ok(ToolResult::success(format!(
                    "{} not found in your OpenCrabs home ({}). No content available.",
                    canonical, canonical
                ))),
            },
        }
    }
}