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();
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)
};
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,
};
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();
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));
}
}
if let Some(ov) = overlay_section(fname) {
out.push_str(&ov);
}
}
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));
}
}
if let Some(ov) = overlay_section(&fname) {
out.push_str(&ov);
}
seen.insert(fname.to_lowercase());
}
}
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))
};
}
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
)));
}
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))
}
}
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
))),
},
}
}
}
#[cfg(test)]
#[path = "load_brain_file_tests.rs"]
mod load_brain_file_tests;