1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
//! 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. \
Pass an optional `query` to get back ONLY the sections that match, instead of the whole \
file — use it when you want to check what the rules say about something specific \
(e.g. name=\"MEMORY.md\", query=\"telegram owner gate\"). Cheap, so prefer it over \
loading a large file in full. \
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."
},
"query": {
"type": "string",
"description": "Optional. Return only the sections of the file matching this text, instead of the whole file. Whole sections are returned, so a rule is never cut in half."
}
},
"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()));
}
// Optional: return only matching sections rather than the whole file.
let query = input
.get("query")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
// Skill-slug form (issue #131): a bare skill name (no `.md`, no
// separators) resolves through the skill registry — same rules as
// slash invocation — and returns the prompt body. This gives
// agents a sanctioned reload surface that works where the raw
// filename form cannot (skills live in subdirectories). A success
// here marks the skill as SEEN for this session, feeding the
// post-compaction inventory stamp (#125/#131 union).
// A bare name that resolves to no known skill falls through to the
// brain-file handling below, which produces the "not found" error.
if !name.ends_with(".md")
&& !name.contains('/')
&& !name.contains('\\')
&& let Some(skill) = crate::brain::skills::resolve_skill(name)
{
super::seen_skills::mark_seen(ctx.session_id, &skill.name);
tracing::info!(
"load_brain_file: resolved skill slug '{}', marked seen for session {}",
skill.name,
ctx.session_id
);
let body = skill.prompt_body();
return Ok(ToolResult::success(if query.is_empty() {
format!("--- skill: {} ---\n{}", skill.name, body)
} else {
let matches = crate::brain::brain_sections::find_sections(&body, query);
tracing::info!(
"load_brain_file(skill {}): query={query:?}: {} section(s) returned",
skill.name,
matches.sections.len()
);
matches.render(&format!("skill: {}", skill.name), query)
}));
}
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();
// A query returns only the matching sections (#800). Runs over
// the profile file AND its project overlay, since that is what
// loading the file in full would have given.
if !query.is_empty() {
let mut combined = trimmed.to_string();
if let Some(ref ov) = overlay {
if !combined.is_empty() {
combined.push_str("\n\n");
}
combined.push_str(ov.trim_end());
}
let matches = crate::brain::brain_sections::find_sections(&combined, query);
tracing::info!(
"load_brain_file({canonical}) query={query:?}: {} section(s) returned, {} omitted",
matches.sections.len(),
matches.omitted
);
return Ok(ToolResult::success(matches.render(canonical, query)));
}
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
))),
},
}
}
}