microclaw_engine/
subdirectory_hints.rs1use std::collections::HashSet;
19use std::path::{Path, PathBuf};
20
21const HINT_FILENAMES: &[&str] = &[
22 "AGENTS.md",
23 "agents.md",
24 "CLAUDE.md",
25 "claude.md",
26 ".cursorrules",
27];
28
29const PATH_ARG_KEYS: &[&str] = &["path", "file_path", "workdir", "cwd"];
31
32const COMMAND_TOOLS: &[&str] = &["bash"];
35
36const MAX_HINT_CHARS: usize = 8_000;
37const MAX_ANCESTOR_WALK: usize = 5;
38
39pub struct SubdirectoryHintTracker {
40 working_dir: PathBuf,
41 loaded_dirs: HashSet<PathBuf>,
42}
43
44impl SubdirectoryHintTracker {
45 pub fn new(working_dir: PathBuf) -> Self {
46 let working_dir = working_dir.canonicalize().unwrap_or(working_dir);
47 let mut loaded_dirs = HashSet::new();
48 loaded_dirs.insert(working_dir.clone());
50 Self {
51 working_dir,
52 loaded_dirs,
53 }
54 }
55
56 pub fn check_tool_call(&mut self, tool_name: &str, args: &serde_json::Value) -> Option<String> {
59 let dirs = self.extract_directories(tool_name, args);
60 if dirs.is_empty() {
61 return None;
62 }
63 let mut found = Vec::new();
64 for d in dirs {
65 if let Some(hint) = self.load_hints_for_directory(&d) {
66 found.push(hint);
67 }
68 }
69 if found.is_empty() {
70 None
71 } else {
72 Some(format!("\n\n{}", found.join("\n\n")))
73 }
74 }
75
76 fn extract_directories(&self, tool_name: &str, args: &serde_json::Value) -> Vec<PathBuf> {
77 let mut candidates: HashSet<PathBuf> = HashSet::new();
78 let obj = match args.as_object() {
79 Some(o) => o,
80 None => return Vec::new(),
81 };
82 for key in PATH_ARG_KEYS {
83 if let Some(s) = obj.get(*key).and_then(|v| v.as_str()) {
84 self.add_path_candidate(s, &mut candidates);
85 }
86 }
87 if COMMAND_TOOLS.contains(&tool_name) {
88 if let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) {
89 self.extract_paths_from_command(cmd, &mut candidates);
90 }
91 }
92 candidates.into_iter().collect()
93 }
94
95 fn add_path_candidate(&self, raw: &str, candidates: &mut HashSet<PathBuf>) {
96 if raw.trim().is_empty() {
97 return;
98 }
99 let raw = raw.trim();
100 if raw.starts_with("http://") || raw.starts_with("https://") || raw.starts_with("git@") {
102 return;
103 }
104 let mut p = PathBuf::from(raw);
105 if !p.is_absolute() {
106 p = self.working_dir.join(p);
107 }
108 let resolved = p.canonicalize().unwrap_or(p);
109 let mut current = if resolved.is_file() || resolved.extension().is_some() {
111 resolved
112 .parent()
113 .map(|p| p.to_path_buf())
114 .unwrap_or(resolved)
115 } else {
116 resolved
117 };
118 for _ in 0..MAX_ANCESTOR_WALK {
119 if self.loaded_dirs.contains(¤t) {
120 break;
121 }
122 if current.is_dir() {
123 candidates.insert(current.clone());
124 }
125 let parent = current.parent().map(|p| p.to_path_buf());
126 match parent {
127 Some(p) if p != current => current = p,
128 _ => break,
129 }
130 }
131 }
132
133 fn extract_paths_from_command(&self, cmd: &str, candidates: &mut HashSet<PathBuf>) {
134 for token in cmd.split_whitespace() {
135 if token.starts_with('-') {
136 continue;
137 }
138 if !token.contains('/') && !token.contains('.') {
139 continue;
140 }
141 self.add_path_candidate(token, candidates);
142 }
143 }
144
145 fn load_hints_for_directory(&mut self, directory: &Path) -> Option<String> {
146 self.loaded_dirs.insert(directory.to_path_buf());
149 for filename in HINT_FILENAMES {
150 let hint_path = directory.join(filename);
151 if !hint_path.is_file() {
152 continue;
153 }
154 let content = match std::fs::read_to_string(&hint_path) {
155 Ok(s) => s.trim().to_string(),
156 Err(_) => continue,
157 };
158 if content.is_empty() {
159 continue;
160 }
161 let total_chars = content.chars().count();
162 let truncated = if total_chars > MAX_HINT_CHARS {
163 let prefix: String = content.chars().take(MAX_HINT_CHARS).collect();
164 format!(
165 "{}\n\n[...truncated {}: {} chars total]",
166 prefix, filename, total_chars
167 )
168 } else {
169 content
170 };
171 let display = hint_path
172 .strip_prefix(&self.working_dir)
173 .map(|p| p.to_string_lossy().into_owned())
174 .unwrap_or_else(|_| hint_path.to_string_lossy().into_owned());
175 return Some(format!(
176 "[Subdirectory context discovered: {display}]\n{truncated}"
177 ));
178 }
179 None
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use serde_json::json;
187
188 fn tmp_root() -> PathBuf {
189 let dir = std::env::temp_dir().join(format!("microclaw_subhint_{}", uuid::Uuid::new_v4()));
190 std::fs::create_dir_all(&dir).unwrap();
191 dir.canonicalize().unwrap()
192 }
193
194 #[test]
195 fn loads_subdir_agents_md_on_first_touch() {
196 let root = tmp_root();
197 let sub = root.join("backend");
198 std::fs::create_dir_all(&sub).unwrap();
199 std::fs::write(sub.join("AGENTS.md"), "# Backend conventions\nUse async.").unwrap();
200 std::fs::write(sub.join("main.py"), "print('hi')").unwrap();
201
202 let mut tracker = SubdirectoryHintTracker::new(root.clone());
203 let hint = tracker
204 .check_tool_call("read_file", &json!({"path": "backend/main.py"}))
205 .expect("should emit a hint");
206 assert!(hint.contains("Backend conventions"));
207 assert!(hint.contains("AGENTS.md") || hint.contains("backend"));
208
209 let again = tracker.check_tool_call("read_file", &json!({"path": "backend/main.py"}));
211 assert!(again.is_none());
212
213 let _ = std::fs::remove_dir_all(&root);
214 }
215
216 #[test]
217 fn ignores_root_directory() {
218 let root = tmp_root();
219 std::fs::write(root.join("AGENTS.md"), "root context").unwrap();
220 std::fs::write(root.join("a.txt"), "data").unwrap();
221 let mut tracker = SubdirectoryHintTracker::new(root.clone());
222 let hint = tracker.check_tool_call("read_file", &json!({"path": "a.txt"}));
223 assert!(
224 hint.is_none(),
225 "root context should be loaded by system prompt, not hint"
226 );
227 let _ = std::fs::remove_dir_all(&root);
228 }
229
230 #[test]
231 fn walks_ancestors_until_hint_found() {
232 let root = tmp_root();
233 let deep = root.join("a/b/c");
234 std::fs::create_dir_all(&deep).unwrap();
235 std::fs::write(root.join("a/AGENTS.md"), "ancestor hint").unwrap();
236 std::fs::write(deep.join("file.txt"), "hi").unwrap();
237
238 let mut tracker = SubdirectoryHintTracker::new(root.clone());
239 let hint = tracker
240 .check_tool_call("read_file", &json!({"path": "a/b/c/file.txt"}))
241 .expect("should walk up to a/AGENTS.md");
242 assert!(hint.contains("ancestor hint"));
243 let _ = std::fs::remove_dir_all(&root);
244 }
245
246 #[test]
247 fn extracts_paths_from_bash_command() {
248 let root = tmp_root();
249 let sub = root.join("frontend");
250 std::fs::create_dir_all(&sub).unwrap();
251 std::fs::write(sub.join("CLAUDE.md"), "frontend rules").unwrap();
252 std::fs::write(sub.join("page.tsx"), "x").unwrap();
253
254 let mut tracker = SubdirectoryHintTracker::new(root.clone());
255 let hint = tracker
256 .check_tool_call("bash", &json!({"command": "cat frontend/page.tsx"}))
257 .expect("should pull frontend hint from bash command");
258 assert!(hint.contains("frontend rules"));
259 let _ = std::fs::remove_dir_all(&root);
260 }
261
262 #[test]
263 fn truncates_oversized_hint() {
264 let root = tmp_root();
265 let sub = root.join("big");
266 std::fs::create_dir_all(&sub).unwrap();
267 let huge = "x".repeat(MAX_HINT_CHARS + 5_000);
268 std::fs::write(sub.join("AGENTS.md"), &huge).unwrap();
269 std::fs::write(sub.join("file"), "y").unwrap();
270
271 let mut tracker = SubdirectoryHintTracker::new(root.clone());
272 let hint = tracker
273 .check_tool_call("read_file", &json!({"path": "big/file"}))
274 .unwrap();
275 assert!(hint.contains("[...truncated"));
276 let _ = std::fs::remove_dir_all(&root);
277 }
278
279 #[test]
280 fn no_path_arg_returns_none() {
281 let root = tmp_root();
282 let mut tracker = SubdirectoryHintTracker::new(root.clone());
283 let hint = tracker.check_tool_call("web_search", &json!({"query": "rust"}));
284 assert!(hint.is_none());
285 let _ = std::fs::remove_dir_all(&root);
286 }
287
288 #[test]
289 fn url_paths_are_skipped() {
290 let root = tmp_root();
291 let mut tracker = SubdirectoryHintTracker::new(root.clone());
292 let hint = tracker.check_tool_call(
293 "web_fetch",
294 &json!({"url": "https://example.com/some/path"}),
295 );
296 assert!(hint.is_none());
297 let _ = std::fs::remove_dir_all(&root);
298 }
299}