1pub 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 Read {
25 path: String,
26 },
27 Edit {
29 path: String,
30 },
31 Search,
33 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 pub ts: i64,
48 pub kind: Kind,
49 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 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 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 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
126pub 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(y, mo, d) * 24 + h) * 60 + mi) * 60_000 + sec * 1000 + ms
137}
138
139pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
144 let y = if m <= 2 { y - 1 } else { y };
145 let era = y.div_euclid(400);
146 let yoe = y - era * 400;
147 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
148 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
149 era * 146_097 + doe - 719_468
150}
151
152pub fn civil_from_days(z: i64) -> (i64, i64, i64) {
155 let z = z + 719_468;
156 let era = z.div_euclid(146_097);
157 let doe = z - era * 146_097;
158 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
159 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
160 let mp = (5 * doy + 2) / 153;
161 let d = doy - (153 * mp + 2) / 5 + 1;
162 let m = if mp < 10 { mp + 3 } else { mp - 9 };
163 (yoe + era * 400 + i64::from(m <= 2), m, d)
164}
165
166pub const CHARS_PER_TOKEN: usize = 4;
170
171pub fn approx_tokens(v: &serde_json::Value) -> u64 {
173 match v {
174 serde_json::Value::String(s) => (s.len() / CHARS_PER_TOKEN) as u64,
175 serde_json::Value::Array(a) => a.iter().map(approx_tokens).sum(),
176 serde_json::Value::Object(o) => o.get("text").or_else(|| o.get("content")).map(approx_tokens).unwrap_or(0),
177 _ => 0,
178 }
179}
180
181pub fn result_text(v: &serde_json::Value) -> String {
183 match v {
184 serde_json::Value::String(s) => s.clone(),
185 serde_json::Value::Array(a) => a.iter().map(result_text).collect::<Vec<_>>().join("\n"),
186 serde_json::Value::Object(o) => o.get("text").or_else(|| o.get("content")).map(result_text).unwrap_or_default(),
187 _ => String::new(),
188 }
189}
190
191pub fn repo_key(cwd: &str) -> String {
196 let mut p = PathBuf::from(cwd);
197 let mut at_git_root = false;
198 let mut cur: Option<&Path> = Some(p.as_path());
199 while let Some(dir) = cur {
200 if dir.join(".git").exists() {
201 p = dir.to_path_buf();
202 at_git_root = true;
203 break;
204 }
205 cur = dir.parent();
206 }
207 let s = p.to_string_lossy().into_owned();
208 for marker in ["/.claude/worktrees/", "-worktrees/", "/.worktrees/"] {
209 if let Some(i) = s.find(marker) {
210 if at_git_root && s[i + marker.len()..].trim_end_matches('/').contains('/') {
211 continue; }
213 return s[..i].trim_end_matches('/').to_string();
214 }
215 }
216 s
217}
218
219pub fn jsonl_files(root: &Path) -> Vec<PathBuf> {
221 let mut out = Vec::new();
222 let mut stack = vec![root.to_path_buf()];
223 while let Some(dir) = stack.pop() {
224 let Ok(rd) = std::fs::read_dir(&dir) else { continue };
225 for e in rd.flatten() {
226 let path = e.path();
227 match e.file_type() {
228 Ok(t) if t.is_dir() => stack.push(path),
229 Ok(t) if t.is_file() && path.extension().is_some_and(|x| x == "jsonl") => out.push(path),
230 _ => {}
231 }
232 }
233 }
234 out
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn parses_timestamps() {
243 assert_eq!(parse_ts("1970-01-01T00:00:00.000Z"), 0);
244 assert_eq!(parse_ts("2026-09-15T22:18:11.638Z"), 1_789_510_691_638);
245 assert_eq!(parse_ts("garbage"), 0);
246 }
247
248 #[test]
249 fn the_calendar_round_trips_and_knows_its_dates() {
250 assert_eq!(days_from_civil(1970, 1, 1), 0);
251 assert_eq!(days_from_civil(2000, 3, 1), 11_017);
252 assert_eq!(days_from_civil(2026, 10, 28), 20_754);
253 assert_eq!(civil_from_days(-1), (1969, 12, 31));
254 assert_eq!(civil_from_days(days_from_civil(2024, 2, 29)), (2024, 2, 29), "a leap day");
255 for z in days_from_civil(1600, 1, 1)..days_from_civil(2400, 1, 1) {
257 let (y, m, d) = civil_from_days(z);
258 assert_eq!(days_from_civil(y, m, d), z);
259 assert!((1..=12).contains(&m) && (1..=31).contains(&d), "{z}: {y}-{m}-{d}");
260 }
261 }
262
263 #[test]
264 fn a_token_is_four_bytes_of_text() {
265 assert_eq!(approx_tokens(&serde_json::json!("12345678")), 2);
266 assert_eq!(approx_tokens(&serde_json::json!([{"text": "1234567"}, "abcd"])), 2, "each part rounds down");
267 }
268
269 #[test]
270 fn folds_worktrees() {
271 assert_eq!(repo_key("/nonexistent/repo/.claude/worktrees/x"), "/nonexistent/repo");
272 assert_eq!(repo_key("/nonexistent/ai_lab-worktrees/lean-a"), "/nonexistent/ai_lab");
273 }
274
275 #[test]
278 fn a_repo_nested_in_a_worktree_keeps_its_name() {
279 let base = std::env::temp_dir().join(format!("borg-repo-key-{}", std::process::id()));
280 let _ = std::fs::remove_dir_all(&base);
281 let wt = base.join("repo/.claude/worktrees/w");
282 let nested = wt.join("target/home/work/relay");
283 std::fs::create_dir_all(nested.join(".git")).unwrap();
284 std::fs::write(wt.join(".git"), "gitdir: elsewhere\n").unwrap();
285 std::fs::create_dir_all(wt.join("src")).unwrap();
286 let s = |p: &Path| p.to_string_lossy().into_owned();
287 assert_eq!(repo_key(&s(&nested.join("src"))), s(&nested));
288 assert_eq!(repo_key(&s(&wt.join("src"))), s(&base.join("repo")), "the worktree itself still folds");
289 let _ = std::fs::remove_dir_all(&base);
290 }
291}