Skip to main content

kimetsu_brain/
ambient.rs

1//! v0.4.4: ambient workspace context for the pre-turn hook.
2//!
3//! The MCP `kimetsu_brain_context` and `kimetsu_benchmark_context`
4//! tools (and the equivalent `kimetsu brain context` CLI) used to
5//! depend entirely on the caller producing a useful `query`. When the
6//! user types "fix it" or "continue", the host harness has nothing to
7//! disambiguate — and the brain has no way to surface the right
8//! capsules.
9//!
10//! This module bridges that gap by collecting a small, cheap
11//! workspace fingerprint on every retrieval call:
12//!   * Current git branch (`git rev-parse --abbrev-ref HEAD`).
13//!   * Working-tree dirty state (`git status --short`, top entries).
14//!   * Recently-modified files in the repo (mtime descending, top 5,
15//!     ignoring `.git`, `node_modules`, `target`, etc. via the same
16//!     `ignore` crate the ingest pipeline already uses).
17//!
18//! [`render_as_query_suffix`] formats those into a short, lexically-
19//! AND-semantically retrievable suffix:
20//!
21//! ```text
22//! [workspace: branch=feature/embedder | recent: src/embeddings.rs,
23//!  src/context.rs, src/project.rs | dirty: M src/embeddings.rs,
24//!  ?? src/ambient.rs]
25//! ```
26//!
27//! v0.4.3's semantic retrieval makes the suffix valuable beyond
28//! keyword overlap: branch names, file paths, and dirty-line text
29//! all embed and contribute cosine signal alongside the explicit
30//! query.
31//!
32//! All collection is best-effort: a missing git binary, a non-git
33//! workspace, or a permission error yields an empty field, not an
34//! error. The whole module is opt-out via
35//! `KIMETSU_BRAIN_AMBIENT=off` (or `0`/`false`/`no`/`none`), and a
36//! per-call `include_ambient=false` parameter overrides per call.
37//!
38//! Cost budget: collection runs once per pre-turn hook fire. Target:
39//! <50ms on a warm repo with up to 50k tracked files. Git calls have
40//! a 2-second hard timeout.
41
42use std::path::{Path, PathBuf};
43use std::process::{Command, Stdio};
44use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
45
46use serde::{Deserialize, Serialize};
47
48/// Per-call ambient context snapshot. Serialized as part of the MCP
49/// response so callers can inspect what augmented their query.
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
51pub struct AmbientContext {
52    /// Current git branch, e.g. "main" or "feature/x". `None` if not
53    /// in a git repo, git is missing, or the call timed out.
54    pub branch: Option<String>,
55    /// Top N entries of `git status --short`. Empty when clean OR
56    /// when git isn't available.
57    pub git_status: Vec<StatusEntry>,
58    /// Up to N recently-modified files (relative paths, mtime
59    /// descending). Filtered via the ingest's `ignore` rules so
60    /// `target/` / `node_modules/` / `.git/` don't dominate.
61    pub recent_files: Vec<PathBuf>,
62    /// UTC unix timestamp the snapshot was taken at. Used by
63    /// callers that want to age out stale fingerprints.
64    pub collected_at_unix: i64,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct StatusEntry {
69    /// First two columns of `git status --short` (e.g. " M", "??",
70    /// "A ", "MM"). Preserved verbatim so callers can render git's
71    /// own glyphs in tooltips.
72    pub flag: String,
73    pub path: PathBuf,
74}
75
76/// Tunables. Defaults match the per-turn budget.
77#[derive(Debug, Clone, Copy)]
78pub struct CollectOptions {
79    /// Cap on the number of recent_files returned.
80    pub recent_files_limit: usize,
81    /// Cap on the number of git_status entries returned.
82    pub git_status_limit: usize,
83    /// Per-shell-out timeout for git invocations.
84    pub git_timeout: Duration,
85    /// Hard cap on filesystem walk duration.
86    pub walk_budget: Duration,
87}
88
89impl Default for CollectOptions {
90    fn default() -> Self {
91        Self {
92            recent_files_limit: 5,
93            git_status_limit: 8,
94            git_timeout: Duration::from_secs(2),
95            walk_budget: Duration::from_millis(150),
96        }
97    }
98}
99
100/// v0.4.4: opt-out gate. Reads `KIMETSU_BRAIN_AMBIENT`. Truthy or
101/// unset → enabled (default); "off"/"0"/"false"/"no"/"none"
102/// (case-insensitive) → disabled. Tests respect this so the brain
103/// test isolation pattern can keep ambient collection off when
104/// asserting on deterministic outputs.
105pub fn ambient_enabled() -> bool {
106    match std::env::var("KIMETSU_BRAIN_AMBIENT") {
107        Ok(value) => {
108            let v = value.trim().to_ascii_lowercase();
109            !matches!(v.as_str(), "off" | "0" | "false" | "no" | "none")
110        }
111        Err(_) => true,
112    }
113}
114
115/// Collect the ambient context for `workspace`. Never errors —
116/// returns a default-empty struct on any sub-collection failure.
117pub fn collect(workspace: &Path) -> AmbientContext {
118    collect_with_opts(workspace, &CollectOptions::default())
119}
120
121pub fn collect_with_opts(workspace: &Path, opts: &CollectOptions) -> AmbientContext {
122    let collected_at_unix = SystemTime::now()
123        .duration_since(UNIX_EPOCH)
124        .map(|d| d.as_secs() as i64)
125        .unwrap_or(0);
126    AmbientContext {
127        branch: collect_branch(workspace, opts.git_timeout),
128        git_status: collect_git_status(workspace, opts.git_status_limit, opts.git_timeout),
129        recent_files: collect_recent_files(workspace, opts.recent_files_limit, opts.walk_budget),
130        collected_at_unix,
131    }
132}
133
134/// Render the snapshot as a one-line suffix to append to the query
135/// before retrieval. Empty fields are omitted so the suffix stays
136/// readable. Returns an empty string when the snapshot has nothing
137/// useful to surface (no branch, clean tree, no recent files) —
138/// callers can then skip the augmentation entirely.
139pub fn render_as_query_suffix(ctx: &AmbientContext) -> String {
140    let mut parts: Vec<String> = Vec::new();
141    if let Some(branch) = ctx.branch.as_deref().filter(|s| !s.is_empty()) {
142        parts.push(format!("branch={branch}"));
143    }
144    if !ctx.recent_files.is_empty() {
145        let listed = ctx
146            .recent_files
147            .iter()
148            .map(|p| p.to_string_lossy().replace('\\', "/"))
149            .collect::<Vec<_>>()
150            .join(", ");
151        parts.push(format!("recent: {listed}"));
152    }
153    if !ctx.git_status.is_empty() {
154        let dirty = ctx
155            .git_status
156            .iter()
157            .map(|entry| {
158                format!(
159                    "{} {}",
160                    entry.flag.trim(),
161                    entry.path.to_string_lossy().replace('\\', "/")
162                )
163            })
164            .collect::<Vec<_>>()
165            .join(", ");
166        parts.push(format!("dirty: {dirty}"));
167    }
168    if parts.is_empty() {
169        String::new()
170    } else {
171        format!("\n[workspace: {}]", parts.join(" | "))
172    }
173}
174
175/// Convenience: combine `query` with `render_as_query_suffix(ctx)`.
176/// When the suffix is empty, the query is returned unchanged.
177pub fn augment_query(query: &str, ctx: &AmbientContext) -> String {
178    let suffix = render_as_query_suffix(ctx);
179    if suffix.is_empty() {
180        query.to_string()
181    } else {
182        format!("{query}{suffix}")
183    }
184}
185
186// --------- internals ---------
187
188fn collect_branch(workspace: &Path, timeout: Duration) -> Option<String> {
189    let out = run_git(workspace, &["rev-parse", "--abbrev-ref", "HEAD"], timeout)?;
190    let trimmed = out.trim();
191    if trimmed.is_empty() || trimmed == "HEAD" {
192        // "HEAD" is git's name for a detached-head state; surface
193        // it as None so the suffix doesn't claim a branch we don't
194        // actually have.
195        return None;
196    }
197    Some(trimmed.to_string())
198}
199
200fn collect_git_status(workspace: &Path, limit: usize, timeout: Duration) -> Vec<StatusEntry> {
201    let Some(out) = run_git(workspace, &["status", "--short", "--no-renames"], timeout) else {
202        return Vec::new();
203    };
204    parse_git_status(&out, limit)
205}
206
207fn parse_git_status(stdout: &str, limit: usize) -> Vec<StatusEntry> {
208    let mut entries = Vec::new();
209    for line in stdout.lines() {
210        if entries.len() >= limit {
211            break;
212        }
213        // git status --short format is `XY<space>path` — two
214        // status columns, then a space, then the path. Reject
215        // lines that don't have a space at column 2 so free-form
216        // garbage ("badly-formatted") doesn't accidentally parse
217        // as ("ba", "ly-formatted").
218        let bytes = line.as_bytes();
219        if bytes.len() < 4 || bytes[2] != b' ' {
220            continue;
221        }
222        let flag = line.get(..2).unwrap_or("").to_string();
223        let rest = line.get(3..).unwrap_or("").trim();
224        if rest.is_empty() {
225            continue;
226        }
227        entries.push(StatusEntry {
228            flag,
229            path: PathBuf::from(rest),
230        });
231    }
232    entries
233}
234
235fn collect_recent_files(workspace: &Path, limit: usize, budget: Duration) -> Vec<PathBuf> {
236    if limit == 0 {
237        return Vec::new();
238    }
239    let started = Instant::now();
240    let mut candidates: Vec<(SystemTime, PathBuf)> = Vec::new();
241    let walker = ignore::WalkBuilder::new(workspace)
242        .standard_filters(true)
243        .hidden(true)
244        .git_ignore(true)
245        .git_exclude(true)
246        .max_depth(Some(6))
247        .build();
248    for result in walker {
249        if started.elapsed() > budget {
250            break;
251        }
252        let Ok(entry) = result else { continue };
253        let Some(file_type) = entry.file_type() else {
254            continue;
255        };
256        if !file_type.is_file() {
257            continue;
258        }
259        let Ok(meta) = entry.metadata() else { continue };
260        let Ok(mtime) = meta.modified() else { continue };
261        let abs = entry.path().to_path_buf();
262        let rel = abs.strip_prefix(workspace).unwrap_or(&abs).to_path_buf();
263        // Skip files inside .kimetsu/ — they're framework state, not
264        // user code, and noisy mtimes (every run rewrites trace
265        // files) would dominate the list.
266        if rel
267            .components()
268            .next()
269            .map(|c| c.as_os_str() == ".kimetsu")
270            .unwrap_or(false)
271        {
272            continue;
273        }
274        candidates.push((mtime, rel));
275    }
276    candidates.sort_by(|a, b| b.0.cmp(&a.0));
277    candidates
278        .into_iter()
279        .take(limit)
280        .map(|(_, path)| path)
281        .collect()
282}
283
284fn run_git(workspace: &Path, args: &[&str], timeout: Duration) -> Option<String> {
285    let mut child = Command::new("git")
286        .args(args)
287        .current_dir(workspace)
288        .stdout(Stdio::piped())
289        .stderr(Stdio::null())
290        .stdin(Stdio::null())
291        .spawn()
292        .ok()?;
293    let started = Instant::now();
294    loop {
295        match child.try_wait() {
296            Ok(Some(status)) if status.success() => {
297                use std::io::Read;
298                let mut buf = String::new();
299                if let Some(mut stdout) = child.stdout.take() {
300                    stdout.read_to_string(&mut buf).ok()?;
301                }
302                return Some(buf);
303            }
304            Ok(Some(_)) => return None,
305            Ok(None) => {
306                if started.elapsed() >= timeout {
307                    let _ = child.kill();
308                    return None;
309                }
310                std::thread::sleep(Duration::from_millis(20));
311            }
312            Err(_) => return None,
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn render_omits_empty_fields() {
323        let ctx = AmbientContext::default();
324        assert_eq!(render_as_query_suffix(&ctx), "");
325        assert_eq!(augment_query("plan the patch", &ctx), "plan the patch");
326    }
327
328    #[test]
329    fn render_includes_branch_only_when_present() {
330        let ctx = AmbientContext {
331            branch: Some("feature/embedder".to_string()),
332            ..Default::default()
333        };
334        let suffix = render_as_query_suffix(&ctx);
335        assert!(suffix.contains("branch=feature/embedder"), "got {suffix}");
336        assert!(!suffix.contains("recent:"));
337        assert!(!suffix.contains("dirty:"));
338    }
339
340    #[test]
341    fn render_normalizes_windows_path_separators() {
342        let ctx = AmbientContext {
343            recent_files: vec![PathBuf::from("src\\embeddings.rs")],
344            git_status: vec![StatusEntry {
345                flag: " M".into(),
346                path: PathBuf::from("crates\\kimetsu-brain\\src\\ambient.rs"),
347            }],
348            ..Default::default()
349        };
350        let suffix = render_as_query_suffix(&ctx);
351        assert!(
352            suffix.contains("src/embeddings.rs"),
353            "got {suffix}"
354        );
355        assert!(
356            suffix.contains("crates/kimetsu-brain/src/ambient.rs"),
357            "got {suffix}"
358        );
359        assert!(!suffix.contains('\\'), "backslashes must be normalized");
360    }
361
362    #[test]
363    fn render_collapses_multiple_fields_with_separator() {
364        let ctx = AmbientContext {
365            branch: Some("main".into()),
366            git_status: vec![StatusEntry {
367                flag: "??".into(),
368                path: PathBuf::from("new.rs"),
369            }],
370            recent_files: vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")],
371            collected_at_unix: 0,
372        };
373        let suffix = render_as_query_suffix(&ctx);
374        assert!(suffix.starts_with("\n[workspace:"));
375        assert!(suffix.ends_with(']'));
376        // All three blocks present, separated by " | ".
377        assert!(suffix.contains("branch=main"));
378        assert!(suffix.contains("recent: a.rs, b.rs"));
379        assert!(suffix.contains("dirty: ?? new.rs"));
380        let pipe_count = suffix.matches(" | ").count();
381        assert_eq!(pipe_count, 2, "three blocks -> two separators");
382    }
383
384    #[test]
385    fn augment_query_appends_suffix_when_nonempty() {
386        let ctx = AmbientContext {
387            branch: Some("dev".into()),
388            ..Default::default()
389        };
390        let out = augment_query("fix it", &ctx);
391        assert!(out.starts_with("fix it"));
392        assert!(out.contains("branch=dev"));
393    }
394
395    #[test]
396    fn parse_git_status_handles_typical_lines() {
397        let sample = " M src/a.rs\n?? src/b.rs\nMM src/c.rs\nA  src/d.rs\nbadly-formatted\n";
398        let parsed = parse_git_status(sample, 10);
399        assert_eq!(parsed.len(), 4);
400        assert_eq!(parsed[0].flag, " M");
401        assert_eq!(parsed[0].path, PathBuf::from("src/a.rs"));
402        assert_eq!(parsed[1].flag, "??");
403        assert_eq!(parsed[2].flag, "MM");
404        assert_eq!(parsed[3].flag, "A ");
405    }
406
407    #[test]
408    fn parse_git_status_respects_limit() {
409        let sample =
410            " M one\n M two\n M three\n M four\n M five\n M six\n M seven\n M eight\n M nine\n";
411        let parsed = parse_git_status(sample, 3);
412        assert_eq!(parsed.len(), 3);
413        assert_eq!(parsed[0].path, PathBuf::from("one"));
414        assert_eq!(parsed[2].path, PathBuf::from("three"));
415    }
416
417    #[test]
418    fn ambient_enabled_respects_env() {
419        // Acquire the shared brain test env lock so we don't race
420        // with the embedder/user-brain env tests.
421        let _lock = crate::user_brain::test_env_lock()
422            .lock()
423            .unwrap_or_else(|p| p.into_inner());
424        let prev = std::env::var("KIMETSU_BRAIN_AMBIENT").ok();
425        // SAFETY: env mutation is serialized under the brain test
426        // env lock for the duration of this test.
427        unsafe {
428            std::env::remove_var("KIMETSU_BRAIN_AMBIENT");
429        }
430        assert!(ambient_enabled(), "default ON");
431        for off in ["off", "0", "false", "no", "NONE"] {
432            unsafe {
433                std::env::set_var("KIMETSU_BRAIN_AMBIENT", off);
434            }
435            assert!(!ambient_enabled(), "value {off:?} should disable");
436        }
437        for on in ["on", "1", "true", "yes", "anything-else"] {
438            unsafe {
439                std::env::set_var("KIMETSU_BRAIN_AMBIENT", on);
440            }
441            assert!(ambient_enabled(), "value {on:?} should enable");
442        }
443        unsafe {
444            match prev {
445                Some(v) => std::env::set_var("KIMETSU_BRAIN_AMBIENT", v),
446                None => std::env::remove_var("KIMETSU_BRAIN_AMBIENT"),
447            }
448        }
449    }
450
451    #[test]
452    fn collect_recent_files_skips_dotkimetsu() {
453        // Build a fake workspace with `.kimetsu/` content next to
454        // real source files; the walker must surface the source
455        // files only.
456        let root = std::env::temp_dir().join(format!(
457            "kimetsu-ambient-test-{}",
458            ulid::Ulid::new()
459        ));
460        std::fs::create_dir_all(root.join("src")).unwrap();
461        std::fs::create_dir_all(root.join(".kimetsu/runs")).unwrap();
462        std::fs::write(root.join("src/a.rs"), "// a").unwrap();
463        std::fs::write(root.join("src/b.rs"), "// b").unwrap();
464        std::fs::write(root.join(".kimetsu/runs/01.trace"), "noise").unwrap();
465
466        let files = collect_recent_files(&root, 5, Duration::from_secs(2));
467        assert!(
468            files.iter().all(|p| !p.starts_with(".kimetsu")),
469            ".kimetsu/ entries should be filtered out: {:?}",
470            files
471        );
472        assert!(files.iter().any(|p| p.ends_with("a.rs") || p.ends_with("b.rs")));
473        let _ = std::fs::remove_dir_all(&root);
474    }
475}