Skip to main content

borg_trace/
lib.rs

1//! Canonical touch streams from agent transcripts.
2//!
3//! Every harness (Claude Code, omp, later opencode) is reduced to the same
4//! sequence of [`Event`]s. Phase 0 mines these for headroom; later the same
5//! parser drives offline counterfactual replay of borg's recall policy.
6
7pub mod claude;
8pub mod dialogue;
9pub mod fingerprint;
10pub mod omp;
11pub mod scars;
12
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum Harness {
17    Claude,
18    Omp,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Kind {
23    /// File content pulled into context.
24    Read {
25        path: String,
26    },
27    /// File changed by the agent.
28    Edit {
29        path: String,
30    },
31    /// Grep/glob/symbol search: exploration without a single target file.
32    Search,
33    /// Shell command. `error` is a normalized fingerprint when it failed, `error_line` the
34    /// first error-looking line it was taken from (for showing a scar to a drone).
35    Bash {
36        cmd: String,
37        failed: bool,
38        error: Option<u64>,
39        error_line: Option<String>,
40    },
41    Other,
42}
43
44#[derive(Debug, Clone)]
45pub struct Event {
46    /// Milliseconds since the Unix epoch.
47    pub ts: i64,
48    pub kind: Kind,
49    /// Approximate tokens the tool result added to context (chars / 4).
50    pub result_tokens: u64,
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct Reasoning {
55    pub thinking_blocks: u32,
56    pub thinking_nonempty: u32,
57    pub edits: u32,
58    /// Edits whose assistant message (or the one before) carries visible text.
59    pub edits_with_text: u32,
60}
61
62#[derive(Debug, Clone)]
63pub struct Session {
64    pub id: String,
65    pub harness: Harness,
66    pub cwd: String,
67    pub sidechain: bool,
68    pub start: i64,
69    pub end: i64,
70    pub events: Vec<Event>,
71    pub input_tokens: u64,
72    pub output_tokens: u64,
73    pub reasoning: Reasoning,
74}
75
76impl Session {
77    fn new(id: String, harness: Harness) -> Self {
78        Session {
79            id,
80            harness,
81            cwd: String::new(),
82            sidechain: false,
83            start: i64::MAX,
84            end: i64::MIN,
85            events: Vec::new(),
86            input_tokens: 0,
87            output_tokens: 0,
88            reasoning: Reasoning::default(),
89        }
90    }
91
92    fn see(&mut self, ts: i64) {
93        if ts > 0 {
94            self.start = self.start.min(ts);
95            self.end = self.end.max(ts);
96        }
97    }
98
99    /// Record a finished shell command; reads through the shell become Read touches.
100    pub(crate) fn push_bash(&mut self, ts: i64, cmd: String, failed: bool, content: &serde_json::Value) {
101        let tokens = approx_tokens(content);
102        let reads = fingerprint::bash_read_paths(&cmd);
103        let text = result_text(content);
104        // Pure file reads print code, which may legitimately contain "Error:".
105        let failed = failed || (!fingerprint::only_reads(&cmd) && fingerprint::looks_failed(&text));
106        let error = if failed { fingerprint::error_fingerprint(&text) } else { None };
107        let error_line = if failed { fingerprint::error_line(&text) } else { None };
108        if reads.is_empty() {
109            self.events.push(Event { ts, kind: Kind::Bash { cmd, failed, error, error_line }, result_tokens: tokens });
110            return;
111        }
112        let share = tokens / reads.len() as u64;
113        self.events.push(Event { ts, kind: Kind::Bash { cmd, failed, error, error_line }, result_tokens: 0 });
114        for p in reads {
115            let p = p.strip_prefix("./").map(str::to_string).unwrap_or(p);
116            let path = if p.starts_with('/') || self.cwd.is_empty() { p } else { format!("{}/{}", self.cwd.trim_end_matches('/'), p) };
117            self.events.push(Event { ts, kind: Kind::Read { path }, result_tokens: share });
118        }
119    }
120
121    pub fn is_empty(&self) -> bool {
122        self.events.is_empty() || self.start > self.end
123    }
124}
125
126/// Parse an RFC 3339 UTC timestamp (`2026-09-15T22:18:11.638Z`) to epoch ms.
127pub fn parse_ts(s: &str) -> i64 {
128    let b = s.as_bytes();
129    if b.len() < 19 {
130        return 0;
131    }
132    let num = |r: std::ops::Range<usize>| s.get(r).and_then(|x| x.parse::<i64>().ok()).unwrap_or(0);
133    let (y, mo, d) = (num(0..4), num(5..7), num(8..10));
134    let (h, mi, sec) = (num(11..13), num(14..16), num(17..19));
135    let ms = if b.get(19) == Some(&b'.') { num(20..23) } else { 0 };
136    // Days from civil (Howard Hinnant).
137    let y2 = if mo <= 2 { y - 1 } else { y };
138    let era = y2.div_euclid(400);
139    let yoe = y2 - era * 400;
140    let doy = (153 * (mo + if mo > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
141    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
142    let days = era * 146097 + doe - 719468;
143    ((days * 24 + h) * 60 + mi) * 60_000 + sec * 1000 + ms
144}
145
146/// Approximate token count of a JSON tool result.
147pub fn approx_tokens(v: &serde_json::Value) -> u64 {
148    match v {
149        serde_json::Value::String(s) => s.len() as u64 / 4,
150        serde_json::Value::Array(a) => a.iter().map(approx_tokens).sum(),
151        serde_json::Value::Object(o) => o.get("text").or_else(|| o.get("content")).map(approx_tokens).unwrap_or(0),
152        _ => 0,
153    }
154}
155
156/// Flatten a tool result into text (for error fingerprinting).
157pub fn result_text(v: &serde_json::Value) -> String {
158    match v {
159        serde_json::Value::String(s) => s.clone(),
160        serde_json::Value::Array(a) => a.iter().map(result_text).collect::<Vec<_>>().join("\n"),
161        serde_json::Value::Object(o) => o.get("text").or_else(|| o.get("content")).map(result_text).unwrap_or_default(),
162        _ => String::new(),
163    }
164}
165
166/// Resolve the repository a session worked in: nearest ancestor with `.git`,
167/// with borg/claude worktree suffixes folded back onto their parent repo.
168/// Only a worktree itself folds: a repo of its own nested deeper inside a worktree (a test
169/// fixture, a sandbox HOME's repos) keeps its own name.
170pub fn repo_key(cwd: &str) -> String {
171    let mut p = PathBuf::from(cwd);
172    let mut at_git_root = false;
173    let mut cur: Option<&Path> = Some(p.as_path());
174    while let Some(dir) = cur {
175        if dir.join(".git").exists() {
176            p = dir.to_path_buf();
177            at_git_root = true;
178            break;
179        }
180        cur = dir.parent();
181    }
182    let s = p.to_string_lossy().into_owned();
183    for marker in ["/.claude/worktrees/", "-worktrees/", "/.worktrees/"] {
184        if let Some(i) = s.find(marker) {
185            if at_git_root && s[i + marker.len()..].trim_end_matches('/').contains('/') {
186                continue; // the git root lies below the worktree: a nested repo, not the worktree
187            }
188            return s[..i].trim_end_matches('/').to_string();
189        }
190    }
191    s
192}
193
194/// All `*.jsonl` files below `root`.
195pub fn jsonl_files(root: &Path) -> Vec<PathBuf> {
196    let mut out = Vec::new();
197    let mut stack = vec![root.to_path_buf()];
198    while let Some(dir) = stack.pop() {
199        let Ok(rd) = std::fs::read_dir(&dir) else { continue };
200        for e in rd.flatten() {
201            let path = e.path();
202            match e.file_type() {
203                Ok(t) if t.is_dir() => stack.push(path),
204                Ok(t) if t.is_file() && path.extension().is_some_and(|x| x == "jsonl") => out.push(path),
205                _ => {}
206            }
207        }
208    }
209    out
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn parses_timestamps() {
218        assert_eq!(parse_ts("1970-01-01T00:00:00.000Z"), 0);
219        assert_eq!(parse_ts("2026-09-15T22:18:11.638Z"), 1_789_510_691_638);
220        assert_eq!(parse_ts("garbage"), 0);
221    }
222
223    #[test]
224    fn folds_worktrees() {
225        assert_eq!(repo_key("/nonexistent/repo/.claude/worktrees/x"), "/nonexistent/repo");
226        assert_eq!(repo_key("/nonexistent/ai_lab-worktrees/lean-a"), "/nonexistent/ai_lab");
227    }
228
229    /// Found by the dogfood run: a repo nested inside a worktree (there, the sandbox HOME's
230    /// `work/relay` under `target/`) was recorded as the worktree's parent repo.
231    #[test]
232    fn a_repo_nested_in_a_worktree_keeps_its_name() {
233        let base = std::env::temp_dir().join(format!("borg-repo-key-{}", std::process::id()));
234        let _ = std::fs::remove_dir_all(&base);
235        let wt = base.join("repo/.claude/worktrees/w");
236        let nested = wt.join("target/home/work/relay");
237        std::fs::create_dir_all(nested.join(".git")).unwrap();
238        std::fs::write(wt.join(".git"), "gitdir: elsewhere\n").unwrap();
239        std::fs::create_dir_all(wt.join("src")).unwrap();
240        let s = |p: &Path| p.to_string_lossy().into_owned();
241        assert_eq!(repo_key(&s(&nested.join("src"))), s(&nested));
242        assert_eq!(repo_key(&s(&wt.join("src"))), s(&base.join("repo")), "the worktree itself still folds");
243        let _ = std::fs::remove_dir_all(&base);
244    }
245}