use super::brain_file_safety;
use super::brain_verify;
use super::error::Result;
use super::r#trait::{Tool, ToolCapability, ToolExecutionContext, ToolResult};
use async_trait::async_trait;
use serde_json::Value;
pub struct WriteOpenCrabsFileTool;
pub(crate) fn validate_opencrabs_path(path: &str) -> std::result::Result<(), String> {
if path.is_empty() {
return Err("path is required".into());
}
if path.starts_with('/') || path.starts_with('~') {
return Err(format!(
"Use a relative path (e.g. \"MEMORY.md\" or \"memory/2026-03-02.md\"), \
not an absolute path '{}'",
path
));
}
if path.contains("..") {
return Err(format!(
"'{}' contains '..' — path traversal is not allowed",
path
));
}
if path.contains('\0') {
return Err("path contains null bytes".into());
}
if path.starts_with("profiles/") {
return Err(format!(
"Path '{}' starts with 'profiles/' which looks like you included a directory prefix. \
This tool expects a relative path from your home directory. \
For example: pass \"TOOLS.md\" not \"profiles/ops/TOOLS.md\", \
or \"memory/note.md\" not \"profiles/ops/memory/note.md\".",
path
));
}
Ok(())
}
fn verify_or_rollback(
full_path: &std::path::Path,
content: &str,
backup_path: &Option<std::path::PathBuf>,
) -> std::result::Result<(), String> {
use std::io::Write;
let file_name = match full_path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => return Ok(()), };
let violations = brain_verify::verify_brain_file(file_name, content);
if violations.is_empty() {
crate::db::repository::AnalyticsEventRepository::emit_brain_verify(file_name, "pass", None);
return Ok(());
}
if let Some(bak) = backup_path
&& let Ok(original) = std::fs::read_to_string(bak)
{
match std::fs::File::create(full_path).and_then(|mut f| f.write_all(original.as_bytes())) {
Ok(()) => tracing::warn!(
"write_opencrabs_file: verification failed, rolled back {}: {}",
file_name,
violations.join("; ")
),
Err(e) => tracing::error!(
"write_opencrabs_file: verification failed for {} AND rollback failed ({e}) \
— the rejected content is still on disk: {}",
file_name,
violations.join("; ")
),
}
}
let joined = violations.join("; ");
crate::db::repository::AnalyticsEventRepository::emit_brain_verify(
file_name,
"rollback",
Some(&joined),
);
Err(format!(
"Brain file verification failed for {}: {}. Write rolled back.",
file_name, joined
))
}
fn memory_belief_key_value(line: &str) -> Option<(String, String)> {
let line = line.trim();
if line.is_empty()
|| line.starts_with('#')
|| line.starts_with("---")
|| line.starts_with('*')
|| line.chars().count() < 12
{
return None;
}
let topic: String = line
.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
.collect::<String>()
.split_whitespace()
.take(6)
.collect::<Vec<_>>()
.join(" ");
if topic.is_empty() {
return None;
}
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
topic.hash(&mut hasher);
Some((format!("memory:{:016x}", hasher.finish()), line.to_string()))
}
fn track_memory_belief(path_str: &str, written: &str) {
let file_name = std::path::Path::new(path_str)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if file_name != "MEMORY.md" {
return;
}
for line in written.lines() {
let Some((key, value)) = memory_belief_key_value(line) else {
continue;
};
let result = super::epistemic::add_belief(
&key,
&value,
super::epistemic::Confidence::Inferred,
"write_opencrabs_file:MEMORY.md",
);
if let super::epistemic::ContradictionResult::Contradicted {
old_value,
new_value,
} = result
{
tracing::warn!(
"write_opencrabs_file: MEMORY.md belief contradicted — '{old_value}' → '{new_value}'"
);
}
}
}
#[async_trait]
impl Tool for WriteOpenCrabsFileTool {
fn name(&self) -> &str {
"write_opencrabs_file"
}
fn description(&self) -> &str {
"Write or edit any file within the OpenCrabs home directory. \
Use this for brain files (MEMORY.md, USER.md, AGENTS.md, SOUL.md, etc.), \
config files (commands.toml), memory logs, and any other app files. \
The standard edit_file/write_file tools cannot reach the home directory — use this instead. \
\
**Path rules:** \
- Pass a relative path from your home directory (e.g. \"MEMORY.md\", \"memory/note.md\", \"rsi/improvements.md\"). \
- No leading slash, no '..' in paths. \
- Do NOT include any directory prefix that duplicates your home path. \
\
Supports three operations: \
\"overwrite\" replaces entire file content, \
\"append\" adds text to the end, \
\"replace\" does a find-and-replace within the file. \
\
Protected brain files are append-only by default. To shrink/clean up a brain file \
(remove outdated content), set cleanup_intent=true — this requires explicit user \
approval and is NOT available in autonomous RSI operations."
}
fn input_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path from your home directory (e.g. \"MEMORY.md\", \"memory/2026-03-02.md\", \"rsi/improvements.md\", \"commands.toml\"). No leading slash, no '..'. Do not include any prefix that duplicates your home path."
},
"operation": {
"type": "string",
"enum": ["overwrite", "append", "replace"],
"description": "\"overwrite\": replace entire file. \"append\": add to end. \"replace\": find old_text and replace with new_text."
},
"content": {
"type": "string",
"description": "Content to write (required for overwrite and append)."
},
"old_text": {
"type": "string",
"description": "Text to find (required for replace)."
},
"new_text": {
"type": "string",
"description": "Replacement text (required for replace)."
},
"dedup_intent": {
"type": "boolean",
"description": "Set to true ONLY when shrinking a protected brain file (TOOLS.md, MEMORY.md, SOUL.md, USER.md, AGENTS.md, CODE.md, SECURITY.md, BOOT.md) to deduplicate. Brain files are append-only — any overwrite/replace whose result is shorter than the existing file is rejected unless dedup_intent=true AND every original line still appears in the result."
},
"cleanup_intent": {
"type": "boolean",
"description": "Set to true ONLY when you need to intentionally clean up a protected brain file (remove outdated content, consolidate sections, etc.). This bypasses the append-only restriction and allows shrinking. Requires explicit user approval (this tool has requires_approval: true). This parameter is NOT available in the autonomous RSI self_improve tool — only user-initiated operations can clean up brain files."
}
},
"required": ["path", "operation"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::WriteFiles]
}
fn requires_approval(&self) -> bool {
true
}
async fn execute(&self, input: Value, _ctx: &ToolExecutionContext) -> Result<ToolResult> {
let path_str = input
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if let Err(e) = validate_opencrabs_path(path_str) {
return Ok(ToolResult::error(e));
}
let operation = input
.get("operation")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let home = crate::config::opencrabs_home();
let full_path = home.join(path_str);
match operation {
"overwrite" => {
let content = match input.get("content").and_then(|v| v.as_str()) {
Some(c) => c,
None => {
return Ok(ToolResult::error(
"content is required for overwrite".into(),
));
}
};
use crate::brain::tools::brain_file_safety;
if brain_file_safety::is_protected_path(&full_path) {
let existing = std::fs::read_to_string(&full_path).unwrap_or_default();
let dedup_intent = input
.get("dedup_intent")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let cleanup_intent = input
.get("cleanup_intent")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if let brain_file_safety::ShrinkCheck::Rejected { message } =
brain_file_safety::check_no_shrink(
&full_path,
&existing,
content,
dedup_intent,
cleanup_intent,
false, )
{
return Ok(ToolResult::error(message));
}
if dedup_intent || cleanup_intent {
let removed =
crate::brain::rsi_pruned::detect_removed_sections(&existing, content);
if !removed.is_empty() {
let mut pruned_state = crate::brain::rsi_pruned::PrunedState::load();
pruned_state.record_pruned(path_str, removed);
if let Err(e) = pruned_state.save() {
tracing::warn!(
"write_opencrabs_file (create-update path): recorded {} pruned header(s) for {} \
but pruned.toml save failed: {} — sync_templates() will re-add those sections \
on the next sync until this is fixed",
pruned_state
.pruned
.get(path_str)
.map(|h| h.len())
.unwrap_or(0),
path_str,
e
);
}
}
}
}
if let Some(parent) = full_path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
return Ok(ToolResult::error(format!(
"Failed to create directory: {}",
e
)));
}
let backup_path = brain_file_safety::backup_before_write(&full_path)
.ok()
.flatten();
match std::fs::write(&full_path, content) {
Ok(()) => {
if let Err(msg) = verify_or_rollback(&full_path, content, &backup_path) {
return Ok(ToolResult::error(msg));
}
track_memory_belief(path_str, content);
Ok(ToolResult::success(format!(
"Wrote {} bytes to {}",
content.len(),
full_path.display()
)))
}
Err(e) => Ok(ToolResult::error(format!(
"Failed to write {}: {}",
path_str, e
))),
}
}
"append" => {
let content = match input.get("content").and_then(|v| v.as_str()) {
Some(c) => c,
None => return Ok(ToolResult::error("content is required for append".into())),
};
use crate::brain::tools::brain_file_safety::{
self, AppendDedup, filter_duplicate_append,
};
let effective_content = if brain_file_safety::is_protected_path(&full_path) {
let existing = std::fs::read_to_string(&full_path).unwrap_or_default();
match filter_duplicate_append(&existing, content) {
AppendDedup::AllNew => content.to_string(),
AppendDedup::Filtered {
filtered_content,
skipped_paragraphs,
} => {
tracing::info!(
"write_opencrabs_file: filtered {skipped_paragraphs} duplicate paragraph(s) from append to {path_str}"
);
filtered_content
}
AppendDedup::AllDuplicate => {
return Ok(ToolResult::error(format!(
"Content already exists in {}. Skipping duplicate append. \
Use replace if you want to update existing content.",
path_str
)));
}
}
} else {
content.to_string()
};
if let Some(parent) = full_path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
return Ok(ToolResult::error(format!(
"Failed to create directory: {}",
e
)));
}
let backup_path = brain_file_safety::backup_before_write(&full_path)
.ok()
.flatten();
use std::io::Write;
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&full_path)
{
Ok(mut f) => match f.write_all(effective_content.as_bytes()) {
Ok(()) => {
if let Ok(full_content) = std::fs::read_to_string(&full_path)
&& let Err(msg) =
verify_or_rollback(&full_path, &full_content, &backup_path)
{
return Ok(ToolResult::error(msg));
}
track_memory_belief(path_str, &effective_content);
if brain_file_safety::is_protected_path(&full_path) {
let brain_dir = crate::config::opencrabs_home();
let filed =
crate::brain::dedup_scan::scan_after_brain_write(&brain_dir);
if filed > 0 {
tracing::info!(
"write_opencrabs_file: cross-file scan filed {filed} dedup proposal(s) after append to {path_str}"
);
}
}
Ok(ToolResult::success(format!(
"Appended {} bytes to {}",
effective_content.len(),
full_path.display()
)))
}
Err(e) => Ok(ToolResult::error(format!(
"Failed to append to {}: {}",
path_str, e
))),
},
Err(e) => Ok(ToolResult::error(format!(
"Failed to open {}: {}",
path_str, e
))),
}
}
"replace" => {
let old_text = match input.get("old_text").and_then(|v| v.as_str()) {
Some(t) => t,
None => {
return Ok(ToolResult::error("old_text is required for replace".into()));
}
};
let new_text = match input.get("new_text").and_then(|v| v.as_str()) {
Some(t) => t,
None => {
return Ok(ToolResult::error("new_text is required for replace".into()));
}
};
let existing = match std::fs::read_to_string(&full_path) {
Ok(s) => s,
Err(_) => {
return Ok(ToolResult::error(format!(
"{} not found in your OpenCrabs home. Use overwrite to create it.",
path_str
)));
}
};
use unicode_normalization::UnicodeNormalization;
let existing_nfc: String = existing.nfc().collect();
let old_text_nfc: String = old_text.nfc().collect();
if !existing_nfc.contains(old_text_nfc.as_str()) {
let file_hex: String = existing_nfc
.bytes()
.take(120)
.map(|b| format!("{:02x}", b))
.collect::<Vec<_>>()
.join(" ");
let search_hex: String = old_text_nfc
.bytes()
.take(120)
.map(|b| format!("{:02x}", b))
.collect::<Vec<_>>()
.join(" ");
tracing::trace!(
"write_opencrabs_file replace miss: file[0..120] hex={} | search[0..120] hex={}",
file_hex,
search_hex
);
return Ok(ToolResult::error(format!(
"old_text not found in {}. No changes made.",
path_str
)));
}
let new_text_nfc: String = new_text.nfc().collect();
let updated =
existing_nfc.replacen(old_text_nfc.as_str(), new_text_nfc.as_str(), 1);
let dedup_intent = input
.get("dedup_intent")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let cleanup_intent = input
.get("cleanup_intent")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if let brain_file_safety::ShrinkCheck::Rejected { message } =
brain_file_safety::check_no_shrink(
&full_path,
&existing_nfc,
&updated,
dedup_intent,
cleanup_intent,
false, )
{
return Ok(ToolResult::error(message));
}
if dedup_intent || cleanup_intent {
let removed =
crate::brain::rsi_pruned::detect_removed_sections(&existing_nfc, &updated);
if !removed.is_empty() {
let mut pruned_state = crate::brain::rsi_pruned::PrunedState::load();
pruned_state.record_pruned(path_str, removed);
if let Err(e) = pruned_state.save() {
tracing::warn!(
"write_opencrabs_file (update path): recorded {} pruned header(s) for {} \
but pruned.toml save failed: {} — sync_templates() will re-add those sections \
on the next sync until this is fixed",
pruned_state
.pruned
.get(path_str)
.map(|h| h.len())
.unwrap_or(0),
path_str,
e
);
}
}
}
let backup_path = brain_file_safety::backup_before_write(&full_path)
.ok()
.flatten();
match std::fs::write(&full_path, &updated) {
Ok(()) => {
if let Err(msg) = verify_or_rollback(&full_path, &updated, &backup_path) {
return Ok(ToolResult::error(msg));
}
track_memory_belief(path_str, new_text);
Ok(ToolResult::success(format!(
"Replaced text in {}",
full_path.display()
)))
}
Err(e) => Ok(ToolResult::error(format!(
"Failed to write {}: {}",
path_str, e
))),
}
}
other => Ok(ToolResult::error(format!(
"Unknown operation '{}'. Use: overwrite, append, replace.",
other
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn memory_belief_skips_noise() {
assert!(memory_belief_key_value("").is_none());
assert!(memory_belief_key_value("## Header line here").is_none());
assert!(memory_belief_key_value("---").is_none());
assert!(memory_belief_key_value("*italic note line*").is_none());
assert!(memory_belief_key_value("too short").is_none());
}
#[test]
fn memory_belief_tracks_rule_line() {
let line = "- NEVER push without explicit user approval";
let (key, value) = memory_belief_key_value(line).expect("rule line should track");
assert!(key.starts_with("memory:"));
assert_eq!(value, line);
}
#[test]
fn memory_belief_same_topic_same_key() {
let (k1, _) =
memory_belief_key_value("- NEVER push without explicit user approval").unwrap();
let (k2, _) =
memory_belief_key_value("- NEVER push without explicit user approval ever").unwrap();
assert_eq!(k1, k2);
}
#[test]
fn memory_belief_different_topic_different_key() {
let (k1, _) =
memory_belief_key_value("- NEVER push without explicit user approval").unwrap();
let (k2, _) = memory_belief_key_value("- ALWAYS run clippy before every commit").unwrap();
assert_ne!(k1, k2);
}
}