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    // Delegate to the config-aware variant with the default (true).
107    ambient_enabled_with(true)
108}
109
110/// W3.2: config-aware ambient gate. Resolution precedence:
111///   1. `KIMETSU_BRAIN_AMBIENT` env is explicitly set → its value wins.
112///   2. Env is unset → `config_ambient` governs.
113///
114/// Callers with a `ProjectConfig` should pass `config.broker.ambient`;
115/// callers without a config (back-compat) can call `ambient_enabled()`.
116pub fn ambient_enabled_with(config_ambient: bool) -> bool {
117    // Precedence: env override > config > default.
118    match std::env::var("KIMETSU_BRAIN_AMBIENT") {
119        Ok(value) => {
120            let v = value.trim().to_ascii_lowercase();
121            // Env is set (even to empty) — respect it.
122            !matches!(v.as_str(), "off" | "0" | "false" | "no" | "none")
123        }
124        // Env unset → config governs.
125        Err(_) => config_ambient,
126    }
127}
128
129/// Collect the ambient context for `workspace`. Never errors —
130/// returns a default-empty struct on any sub-collection failure.
131pub fn collect(workspace: &Path) -> AmbientContext {
132    collect_with_opts(workspace, &CollectOptions::default())
133}
134
135pub fn collect_with_opts(workspace: &Path, opts: &CollectOptions) -> AmbientContext {
136    let collected_at_unix = SystemTime::now()
137        .duration_since(UNIX_EPOCH)
138        .map(|d| d.as_secs() as i64)
139        .unwrap_or(0);
140    AmbientContext {
141        branch: collect_branch(workspace, opts.git_timeout),
142        git_status: collect_git_status(workspace, opts.git_status_limit, opts.git_timeout),
143        recent_files: collect_recent_files(workspace, opts.recent_files_limit, opts.walk_budget),
144        collected_at_unix,
145    }
146}
147
148/// Render the snapshot as a one-line suffix to append to the query
149/// before retrieval. Empty fields are omitted so the suffix stays
150/// readable. Returns an empty string when the snapshot has nothing
151/// useful to surface (no branch, clean tree, no recent files) —
152/// callers can then skip the augmentation entirely.
153pub fn render_as_query_suffix(ctx: &AmbientContext) -> String {
154    let mut parts: Vec<String> = Vec::new();
155    if let Some(branch) = ctx.branch.as_deref().filter(|s| !s.is_empty()) {
156        parts.push(format!("branch={branch}"));
157    }
158    if !ctx.recent_files.is_empty() {
159        let listed = ctx
160            .recent_files
161            .iter()
162            .map(|p| p.to_string_lossy().replace('\\', "/"))
163            .collect::<Vec<_>>()
164            .join(", ");
165        parts.push(format!("recent: {listed}"));
166    }
167    if !ctx.git_status.is_empty() {
168        let dirty = ctx
169            .git_status
170            .iter()
171            .map(|entry| {
172                format!(
173                    "{} {}",
174                    entry.flag.trim(),
175                    entry.path.to_string_lossy().replace('\\', "/")
176                )
177            })
178            .collect::<Vec<_>>()
179            .join(", ");
180        parts.push(format!("dirty: {dirty}"));
181    }
182    if parts.is_empty() {
183        String::new()
184    } else {
185        format!("\n[workspace: {}]", parts.join(" | "))
186    }
187}
188
189/// Convenience: combine `query` with `render_as_query_suffix(ctx)`.
190/// When the suffix is empty, the query is returned unchanged.
191pub fn augment_query(query: &str, ctx: &AmbientContext) -> String {
192    let suffix = render_as_query_suffix(ctx);
193    if suffix.is_empty() {
194        query.to_string()
195    } else {
196        format!("{query}{suffix}")
197    }
198}
199
200// --------- internals ---------
201
202fn collect_branch(workspace: &Path, timeout: Duration) -> Option<String> {
203    let out = run_git(workspace, &["rev-parse", "--abbrev-ref", "HEAD"], timeout)?;
204    let trimmed = out.trim();
205    if trimmed.is_empty() || trimmed == "HEAD" {
206        // "HEAD" is git's name for a detached-head state; surface
207        // it as None so the suffix doesn't claim a branch we don't
208        // actually have.
209        return None;
210    }
211    Some(trimmed.to_string())
212}
213
214fn collect_git_status(workspace: &Path, limit: usize, timeout: Duration) -> Vec<StatusEntry> {
215    let Some(out) = run_git(workspace, &["status", "--short", "--no-renames"], timeout) else {
216        return Vec::new();
217    };
218    parse_git_status(&out, limit)
219}
220
221fn parse_git_status(stdout: &str, limit: usize) -> Vec<StatusEntry> {
222    let mut entries = Vec::new();
223    for line in stdout.lines() {
224        if entries.len() >= limit {
225            break;
226        }
227        // git status --short format is `XY<space>path` — two
228        // status columns, then a space, then the path. Reject
229        // lines that don't have a space at column 2 so free-form
230        // garbage ("badly-formatted") doesn't accidentally parse
231        // as ("ba", "ly-formatted").
232        let bytes = line.as_bytes();
233        if bytes.len() < 4 || bytes[2] != b' ' {
234            continue;
235        }
236        let flag = line.get(..2).unwrap_or("").to_string();
237        let rest = line.get(3..).unwrap_or("").trim();
238        if rest.is_empty() {
239            continue;
240        }
241        entries.push(StatusEntry {
242            flag,
243            path: PathBuf::from(rest),
244        });
245    }
246    entries
247}
248
249fn collect_recent_files(workspace: &Path, limit: usize, budget: Duration) -> Vec<PathBuf> {
250    if limit == 0 {
251        return Vec::new();
252    }
253    let started = Instant::now();
254    let mut candidates: Vec<(SystemTime, PathBuf)> = Vec::new();
255    let walker = ignore::WalkBuilder::new(workspace)
256        .standard_filters(true)
257        .hidden(true)
258        .git_ignore(true)
259        .git_exclude(true)
260        .max_depth(Some(6))
261        .build();
262    for result in walker {
263        if started.elapsed() > budget {
264            break;
265        }
266        let Ok(entry) = result else { continue };
267        let Some(file_type) = entry.file_type() else {
268            continue;
269        };
270        if !file_type.is_file() {
271            continue;
272        }
273        let Ok(meta) = entry.metadata() else { continue };
274        let Ok(mtime) = meta.modified() else { continue };
275        let abs = entry.path().to_path_buf();
276        let rel = abs.strip_prefix(workspace).unwrap_or(&abs).to_path_buf();
277        // Skip files inside .kimetsu/ — they're framework state, not
278        // user code, and noisy mtimes (every run rewrites trace
279        // files) would dominate the list.
280        if rel
281            .components()
282            .next()
283            .map(|c| c.as_os_str() == ".kimetsu")
284            .unwrap_or(false)
285        {
286            continue;
287        }
288        candidates.push((mtime, rel));
289    }
290    candidates.sort_by_key(|b| std::cmp::Reverse(b.0));
291    candidates
292        .into_iter()
293        .take(limit)
294        .map(|(_, path)| path)
295        .collect()
296}
297
298fn run_git(workspace: &Path, args: &[&str], timeout: Duration) -> Option<String> {
299    let mut child = Command::new("git")
300        .args(args)
301        .current_dir(workspace)
302        .stdout(Stdio::piped())
303        .stderr(Stdio::null())
304        .stdin(Stdio::null())
305        .spawn()
306        .ok()?;
307    let started = Instant::now();
308    loop {
309        match child.try_wait() {
310            Ok(Some(status)) if status.success() => {
311                use std::io::Read;
312                let mut buf = String::new();
313                if let Some(mut stdout) = child.stdout.take() {
314                    stdout.read_to_string(&mut buf).ok()?;
315                }
316                return Some(buf);
317            }
318            Ok(Some(_)) => return None,
319            Ok(None) => {
320                if started.elapsed() >= timeout {
321                    let _ = child.kill();
322                    return None;
323                }
324                std::thread::sleep(Duration::from_millis(20));
325            }
326            Err(_) => return None,
327        }
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn render_omits_empty_fields() {
337        let ctx = AmbientContext::default();
338        assert_eq!(render_as_query_suffix(&ctx), "");
339        assert_eq!(augment_query("plan the patch", &ctx), "plan the patch");
340    }
341
342    #[test]
343    fn render_includes_branch_only_when_present() {
344        let ctx = AmbientContext {
345            branch: Some("feature/embedder".to_string()),
346            ..Default::default()
347        };
348        let suffix = render_as_query_suffix(&ctx);
349        assert!(suffix.contains("branch=feature/embedder"), "got {suffix}");
350        assert!(!suffix.contains("recent:"));
351        assert!(!suffix.contains("dirty:"));
352    }
353
354    #[test]
355    fn render_normalizes_windows_path_separators() {
356        let ctx = AmbientContext {
357            recent_files: vec![PathBuf::from("src\\embeddings.rs")],
358            git_status: vec![StatusEntry {
359                flag: " M".into(),
360                path: PathBuf::from("crates\\kimetsu-brain\\src\\ambient.rs"),
361            }],
362            ..Default::default()
363        };
364        let suffix = render_as_query_suffix(&ctx);
365        assert!(suffix.contains("src/embeddings.rs"), "got {suffix}");
366        assert!(
367            suffix.contains("crates/kimetsu-brain/src/ambient.rs"),
368            "got {suffix}"
369        );
370        assert!(!suffix.contains('\\'), "backslashes must be normalized");
371    }
372
373    #[test]
374    fn render_collapses_multiple_fields_with_separator() {
375        let ctx = AmbientContext {
376            branch: Some("main".into()),
377            git_status: vec![StatusEntry {
378                flag: "??".into(),
379                path: PathBuf::from("new.rs"),
380            }],
381            recent_files: vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")],
382            collected_at_unix: 0,
383        };
384        let suffix = render_as_query_suffix(&ctx);
385        assert!(suffix.starts_with("\n[workspace:"));
386        assert!(suffix.ends_with(']'));
387        // All three blocks present, separated by " | ".
388        assert!(suffix.contains("branch=main"));
389        assert!(suffix.contains("recent: a.rs, b.rs"));
390        assert!(suffix.contains("dirty: ?? new.rs"));
391        let pipe_count = suffix.matches(" | ").count();
392        assert_eq!(pipe_count, 2, "three blocks -> two separators");
393    }
394
395    #[test]
396    fn augment_query_appends_suffix_when_nonempty() {
397        let ctx = AmbientContext {
398            branch: Some("dev".into()),
399            ..Default::default()
400        };
401        let out = augment_query("fix it", &ctx);
402        assert!(out.starts_with("fix it"));
403        assert!(out.contains("branch=dev"));
404    }
405
406    #[test]
407    fn parse_git_status_handles_typical_lines() {
408        let sample = " M src/a.rs\n?? src/b.rs\nMM src/c.rs\nA  src/d.rs\nbadly-formatted\n";
409        let parsed = parse_git_status(sample, 10);
410        assert_eq!(parsed.len(), 4);
411        assert_eq!(parsed[0].flag, " M");
412        assert_eq!(parsed[0].path, PathBuf::from("src/a.rs"));
413        assert_eq!(parsed[1].flag, "??");
414        assert_eq!(parsed[2].flag, "MM");
415        assert_eq!(parsed[3].flag, "A ");
416    }
417
418    #[test]
419    fn parse_git_status_respects_limit() {
420        let sample =
421            " 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";
422        let parsed = parse_git_status(sample, 3);
423        assert_eq!(parsed.len(), 3);
424        assert_eq!(parsed[0].path, PathBuf::from("one"));
425        assert_eq!(parsed[2].path, PathBuf::from("three"));
426    }
427
428    #[test]
429    fn ambient_enabled_respects_env() {
430        // Acquire the shared brain test env lock so we don't race
431        // with the embedder/user-brain env tests.
432        let _lock = crate::user_brain::test_env_lock()
433            .lock()
434            .unwrap_or_else(|p| p.into_inner());
435        let prev = std::env::var("KIMETSU_BRAIN_AMBIENT").ok();
436        // SAFETY: env mutation is serialized under the brain test
437        // env lock for the duration of this test.
438        unsafe {
439            std::env::remove_var("KIMETSU_BRAIN_AMBIENT");
440        }
441        assert!(ambient_enabled(), "default ON");
442        for off in ["off", "0", "false", "no", "NONE"] {
443            unsafe {
444                std::env::set_var("KIMETSU_BRAIN_AMBIENT", off);
445            }
446            assert!(!ambient_enabled(), "value {off:?} should disable");
447        }
448        for on in ["on", "1", "true", "yes", "anything-else"] {
449            unsafe {
450                std::env::set_var("KIMETSU_BRAIN_AMBIENT", on);
451            }
452            assert!(ambient_enabled(), "value {on:?} should enable");
453        }
454        unsafe {
455            match prev {
456                Some(v) => std::env::set_var("KIMETSU_BRAIN_AMBIENT", v),
457                None => std::env::remove_var("KIMETSU_BRAIN_AMBIENT"),
458            }
459        }
460    }
461
462    #[test]
463    fn collect_recent_files_skips_dotkimetsu() {
464        // Build a fake workspace with `.kimetsu/` content next to
465        // real source files; the walker must surface the source
466        // files only.
467        let root = std::env::temp_dir().join(format!("kimetsu-ambient-test-{}", ulid::Ulid::new()));
468        std::fs::create_dir_all(root.join("src")).unwrap();
469        std::fs::create_dir_all(root.join(".kimetsu/runs")).unwrap();
470        std::fs::write(root.join("src/a.rs"), "// a").unwrap();
471        std::fs::write(root.join("src/b.rs"), "// b").unwrap();
472        std::fs::write(root.join(".kimetsu/runs/01.trace"), "noise").unwrap();
473
474        let files = collect_recent_files(&root, 5, Duration::from_secs(2));
475        assert!(
476            files.iter().all(|p| !p.starts_with(".kimetsu")),
477            ".kimetsu/ entries should be filtered out: {:?}",
478            files
479        );
480        assert!(
481            files
482                .iter()
483                .any(|p| p.ends_with("a.rs") || p.ends_with("b.rs"))
484        );
485        let _ = std::fs::remove_dir_all(&root);
486    }
487
488    // ── W3.2: ambient_enabled_with tests ─────────────────────────────
489
490    /// W3.2: config=false disables ambient when env is unset.
491    #[test]
492    fn w3_ambient_enabled_with_config_false_when_env_unset() {
493        let _lock = crate::user_brain::test_env_lock()
494            .lock()
495            .unwrap_or_else(|p| p.into_inner());
496        let prev = std::env::var("KIMETSU_BRAIN_AMBIENT").ok();
497        unsafe {
498            std::env::remove_var("KIMETSU_BRAIN_AMBIENT");
499        }
500        // config=false and env unset → disabled.
501        assert!(
502            !ambient_enabled_with(false),
503            "config=false + env unset must be disabled"
504        );
505        // config=true and env unset → enabled (default behavior preserved).
506        assert!(
507            ambient_enabled_with(true),
508            "config=true + env unset must be enabled"
509        );
510        unsafe {
511            match prev {
512                Some(v) => std::env::set_var("KIMETSU_BRAIN_AMBIENT", v),
513                None => std::env::remove_var("KIMETSU_BRAIN_AMBIENT"),
514            }
515        }
516    }
517
518    /// W3.2: env=0 overrides config=true (env wins when disable value).
519    #[test]
520    fn w3_ambient_env_disable_overrides_config_true() {
521        let _lock = crate::user_brain::test_env_lock()
522            .lock()
523            .unwrap_or_else(|p| p.into_inner());
524        let prev = std::env::var("KIMETSU_BRAIN_AMBIENT").ok();
525        unsafe {
526            std::env::set_var("KIMETSU_BRAIN_AMBIENT", "0");
527        }
528        // Even with config=true, env=0 disables.
529        assert!(
530            !ambient_enabled_with(true),
531            "KIMETSU_BRAIN_AMBIENT=0 must override config=true"
532        );
533        unsafe {
534            match prev {
535                Some(v) => std::env::set_var("KIMETSU_BRAIN_AMBIENT", v),
536                None => std::env::remove_var("KIMETSU_BRAIN_AMBIENT"),
537            }
538        }
539    }
540
541    /// W3.2: env=1 overrides config=false (env wins when enable value).
542    #[test]
543    fn w3_ambient_env_enable_overrides_config_false() {
544        let _lock = crate::user_brain::test_env_lock()
545            .lock()
546            .unwrap_or_else(|p| p.into_inner());
547        let prev = std::env::var("KIMETSU_BRAIN_AMBIENT").ok();
548        unsafe {
549            std::env::set_var("KIMETSU_BRAIN_AMBIENT", "1");
550        }
551        // Even with config=false, env=1 enables.
552        assert!(
553            ambient_enabled_with(false),
554            "KIMETSU_BRAIN_AMBIENT=1 must override config=false"
555        );
556        unsafe {
557            match prev {
558                Some(v) => std::env::set_var("KIMETSU_BRAIN_AMBIENT", v),
559                None => std::env::remove_var("KIMETSU_BRAIN_AMBIENT"),
560            }
561        }
562    }
563}