Skip to main content

lean_ctx/core/gotcha_tracker/
mining.rs

1//! Offline trace mining: distill recurring error signatures from a directory of
2//! `.jsonl` transcript/log files (e.g. agent transcripts, CI logs).
3//!
4//! The live loop ([`record_shell_outcome`](super::record_shell_outcome)) learns
5//! from commands run *through* lean-ctx. This module bootstraps that loop from
6//! history: it scans past transcripts for the same unambiguous error markers the
7//! shell detector recognizes (`error[E####]`, `error TS####`, `Traceback`,
8//! `npm ERR!`, `panicked at`, …) and ranks the signatures that recur across the
9//! most sessions — the project's persistent pain points.
10//!
11//! Deterministic and high-precision by design: only structured error markers
12//! match (never free prose), each file counts as one "session", and the output
13//! is read-only — it surfaces signatures for review, it never mutates state.
14
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18use regex::Regex;
19
20use super::normalize_error_signature;
21
22/// Auto-discover the agent-transcripts directory so `lean-ctx learn --mine` works
23/// with zero arguments. Prefers Claude Code's `~/.claude/projects`, then Cursor's
24/// `~/.cursor/agent-transcripts`. Returns the first that exists, or `None`.
25#[must_use]
26pub fn default_transcript_dir() -> Option<PathBuf> {
27    let home = dirs::home_dir()?;
28    [
29        home.join(".claude").join("projects"),
30        home.join(".cursor").join("agent-transcripts"),
31    ]
32    .into_iter()
33    .find(|p| p.is_dir())
34}
35
36/// A recurring error signature distilled from mined transcripts.
37#[derive(Debug, Clone, PartialEq)]
38pub struct MinedSignature {
39    pub signature: String,
40    /// Total matches across all files.
41    pub occurrences: usize,
42    /// Distinct files (sessions) the signature appeared in.
43    pub sessions: usize,
44}
45
46/// High-precision, command-agnostic error markers. Anchored to tokens that do
47/// not occur in ordinary prose, so matching transcript text never false-positives
48/// on a discussion *about* errors.
49fn signature_patterns() -> Vec<Regex> {
50    [
51        r"error\[E\d{4}\][^\n]*",         // Rust / rustc
52        r"error TS\d{4}[^\n]*",           // TypeScript / tsc
53        r"panicked at [^\n]*",            // Rust panic
54        r"npm ERR![^\n]*",                // npm
55        r"ModuleNotFoundError[^\n]*",     // Python
56        r"ImportError: [^\n]*",           // Python
57        r"undefined: [A-Za-z_][\w]*",     // Go
58        r"undefined reference to [^\n]*", // C/C++ linker
59    ]
60    .iter()
61    .filter_map(|p| Regex::new(p).ok())
62    .collect()
63}
64
65/// Extract normalized error signatures from a single blob of text. Pure and
66/// deterministic; returns one entry per match (callers aggregate counts).
67pub fn extract_error_signatures(text: &str) -> Vec<String> {
68    extract_with(text, &signature_patterns())
69}
70
71fn extract_with(text: &str, patterns: &[Regex]) -> Vec<String> {
72    let mut out = Vec::new();
73    for re in patterns {
74        for m in re.find_iter(text) {
75            let sig = normalize_error_signature(m.as_str());
76            if !sig.is_empty() {
77                out.push(sig);
78            }
79        }
80    }
81    out
82}
83
84/// Collect every string value in a parsed JSON line, so mining works regardless
85/// of the transcript's exact schema (message text lives under different keys
86/// across tools). Deterministic depth-first traversal.
87fn collect_strings(value: &serde_json::Value, out: &mut String) {
88    match value {
89        serde_json::Value::String(s) => {
90            out.push_str(s);
91            out.push('\n');
92        }
93        serde_json::Value::Array(arr) => {
94            for v in arr {
95                collect_strings(v, out);
96            }
97        }
98        serde_json::Value::Object(map) => {
99            for v in map.values() {
100                collect_strings(v, out);
101            }
102        }
103        _ => {}
104    }
105}
106
107/// Mine all `.jsonl` files in `dir` for recurring error signatures. Each file is
108/// treated as one session; signatures are ranked by session reach, then total
109/// occurrences, then lexically — fully deterministic.
110pub fn mine_jsonl_dir(dir: &Path) -> Vec<MinedSignature> {
111    let patterns = signature_patterns();
112
113    // Stable file order so traversal is deterministic regardless of FS ordering.
114    let mut files: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
115        Ok(rd) => rd
116            .flatten()
117            .map(|e| e.path())
118            .filter(|p| p.extension().is_some_and(|e| e == "jsonl"))
119            .collect(),
120        Err(_) => return Vec::new(),
121    };
122    files.sort();
123
124    // signature -> (total occurrences, set of file indices it appeared in)
125    let mut occ: BTreeMap<String, usize> = BTreeMap::new();
126    let mut sess: BTreeMap<String, usize> = BTreeMap::new();
127
128    for path in &files {
129        let Ok(content) = std::fs::read_to_string(path) else {
130            continue;
131        };
132        let mut seen_in_file: std::collections::BTreeSet<String> =
133            std::collections::BTreeSet::new();
134        for line in content.lines() {
135            let line = line.trim();
136            if line.is_empty() {
137                continue;
138            }
139            // Pull text out of structured JSON when possible; fall back to the
140            // raw line so plain-text logs still mine.
141            let text = match serde_json::from_str::<serde_json::Value>(line) {
142                Ok(v) => {
143                    let mut buf = String::new();
144                    collect_strings(&v, &mut buf);
145                    buf
146                }
147                Err(_) => line.to_string(),
148            };
149            for sig in extract_with(&text, &patterns) {
150                *occ.entry(sig.clone()).or_insert(0) += 1;
151                seen_in_file.insert(sig);
152            }
153        }
154        for sig in seen_in_file {
155            *sess.entry(sig).or_insert(0) += 1;
156        }
157    }
158
159    let mut result: Vec<MinedSignature> = occ
160        .into_iter()
161        .map(|(signature, occurrences)| {
162            let sessions = sess.get(&signature).copied().unwrap_or(0);
163            MinedSignature {
164                signature,
165                occurrences,
166                sessions,
167            }
168        })
169        .collect();
170
171    result.sort_by(|a, b| {
172        b.sessions
173            .cmp(&a.sessions)
174            .then_with(|| b.occurrences.cmp(&a.occurrences))
175            .then_with(|| a.signature.cmp(&b.signature))
176    });
177    result
178}
179
180/// Render a mining report. `min_sessions` hides one-off signatures so only
181/// genuinely recurring pain points show.
182pub fn format_mining_report(mined: &[MinedSignature], min_sessions: usize) -> String {
183    let recurring: Vec<&MinedSignature> = mined
184        .iter()
185        .filter(|m| m.sessions >= min_sessions)
186        .collect();
187
188    if recurring.is_empty() {
189        return "No recurring error signatures found in the mined transcripts.".to_string();
190    }
191
192    let mut out = format!(
193        "=== Recurring errors across sessions ({} signature(s)) ===\n",
194        recurring.len()
195    );
196    for m in recurring {
197        out.push_str(&format!(
198            "  [{} sessions, {}x] {}\n",
199            m.sessions, m.occurrences, m.signature
200        ));
201    }
202    out.push_str("\nThese recur across past sessions — capture fixes with ctx_knowledge or address the root cause.\n");
203    out
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn extracts_known_error_markers() {
212        let text = "thread 'main' panicked at src/x.rs:1:1\n\
213                    error[E0382]: borrow of moved value: `x`\n\
214                    src/a.ts:3:1 - error TS2304: Cannot find name 'foo'.\n\
215                    ModuleNotFoundError: No module named 'flask'";
216        let sigs = extract_error_signatures(text);
217        assert!(sigs.iter().any(|s| s.contains("E0382")));
218        assert!(sigs.iter().any(|s| s.contains("TS2304")));
219        assert!(sigs.iter().any(|s| s.contains("panicked at")));
220        assert!(sigs.iter().any(|s| s.contains("ModuleNotFoundError")));
221    }
222
223    #[test]
224    fn ignores_prose_without_markers() {
225        let text = "We discussed the error handling strategy and how to fix the failed build.";
226        assert!(
227            extract_error_signatures(text).is_empty(),
228            "discussion about errors must not match"
229        );
230    }
231
232    #[test]
233    fn mines_recurring_signature_across_files() {
234        let dir = tempfile::tempdir().expect("tempdir");
235        // Two separate "sessions" both hitting the same compile error.
236        std::fs::write(
237            dir.path().join("s1.jsonl"),
238            "{\"role\":\"assistant\",\"content\":\"error[E0382]: borrow of moved value: x\"}\n",
239        )
240        .unwrap();
241        std::fs::write(
242            dir.path().join("s2.jsonl"),
243            "{\"role\":\"user\",\"text\":\"error[E0382]: borrow of moved value: x\"}\n",
244        )
245        .unwrap();
246        // A non-jsonl file must be ignored.
247        std::fs::write(dir.path().join("notes.txt"), "error[E9999]: ignore me").unwrap();
248
249        let mined = mine_jsonl_dir(dir.path());
250        let top = mined
251            .iter()
252            .find(|m| m.signature.contains("E0382"))
253            .expect("E0382 mined");
254        assert_eq!(top.sessions, 2, "seen across both transcript files");
255        assert_eq!(top.occurrences, 2);
256        assert!(
257            !mined.iter().any(|m| m.signature.contains("E9999")),
258            "non-.jsonl files are not mined"
259        );
260
261        let report = format_mining_report(&mined, 2);
262        assert!(report.contains("E0382"));
263        assert!(report.contains("2 sessions"));
264    }
265
266    #[test]
267    fn report_hides_one_off_signatures() {
268        let mined = vec![MinedSignature {
269            signature: "error[E0001]: once".to_string(),
270            occurrences: 1,
271            sessions: 1,
272        }];
273        let report = format_mining_report(&mined, 2);
274        assert!(report.contains("No recurring error signatures"));
275    }
276
277    #[test]
278    fn mining_is_deterministic() {
279        let dir = tempfile::tempdir().expect("tempdir");
280        std::fs::write(
281            dir.path().join("a.jsonl"),
282            "{\"content\":\"error[E0382]: moved\"}\n{\"content\":\"error TS2304: missing\"}\n",
283        )
284        .unwrap();
285        assert_eq!(mine_jsonl_dir(dir.path()), mine_jsonl_dir(dir.path()));
286    }
287}