Skip to main content

tokenburn_core/features/copilot/
ops.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::sync::LazyLock;
4
5use anyhow::Result;
6use chrono::{DateTime, Utc};
7use serde_json::Value;
8
9use crate::features::usage::{Row, Tool};
10use crate::utils::cache::{FileCache, Sig};
11use crate::utils::files::{env_path, modified_since, num, read_lines, walk_ext};
12use crate::utils::time::parse_ts;
13
14/// `$COPILOT_HOME` (default `~/.copilot`).
15pub fn root() -> Option<PathBuf> {
16    env_path("COPILOT_HOME").or_else(|| dirs::home_dir().map(|h| h.join(".copilot")))
17}
18
19pub fn collect_copilot(start: DateTime<Utc>) -> Result<Vec<Row>> {
20    match root().map(|r| r.join("session-state")) {
21        Some(dir) if dir.is_dir() => collect_copilot_from(&dir, start),
22        _ => Ok(Vec::new()),
23    }
24}
25
26/// Read every `session-state/<id>.jsonl` and `session-state/<id>/events.jsonl`.
27///
28/// Copilot CLI records usage in one `session.shutdown` event per session, with
29/// per-model counters in `data.modelMetrics`. Sessions that never shut down
30/// cleanly, and older CLI versions that log no usage, yield no rows.
31/// Parsed rows per session file, reused while the file is unchanged.
32static CACHE: LazyLock<FileCache<Vec<Row>>> = LazyLock::new(FileCache::default);
33
34pub fn collect_copilot_from(dir: &Path, start: DateTime<Utc>) -> Result<Vec<Row>> {
35    let files = walk_ext(dir, &["jsonl"]);
36    let live: HashSet<PathBuf> = files.iter().cloned().collect();
37    let mut rows = Vec::new();
38    for file in files {
39        if !modified_since(&file, start) {
40            continue;
41        }
42        let Some(sig) = Sig::of(&file, 0) else {
43            continue;
44        };
45        let parsed = CACHE.get_or_parse(&file, sig, || parse_file(&file));
46        rows.extend(parsed.iter().cloned());
47    }
48    CACHE.prune_under(dir, &live);
49    Ok(rows)
50}
51
52fn parse_file(file: &Path) -> Vec<Row> {
53    let id = session_id(file);
54    let Some(lines) = read_lines(file) else {
55        return Vec::new();
56    };
57    lines
58        // Cheap pre-filter: shutdown events are rare, don't JSON-parse the rest.
59        .filter(|l| l.contains("session.shutdown"))
60        .flat_map(|l| parse_shutdown(&l, &id))
61        .collect()
62}
63
64/// `…/<uuid>/events.jsonl` → `<uuid>`; `…/<uuid>.jsonl` → `<uuid>`.
65fn session_id(file: &Path) -> String {
66    let stem = file
67        .file_stem()
68        .map(|s| s.to_string_lossy().into_owned())
69        .unwrap_or_default();
70    if stem == "events" {
71        file.parent()
72            .and_then(|p| p.file_name())
73            .map(|n| n.to_string_lossy().into_owned())
74            .unwrap_or(stem)
75    } else {
76        stem
77    }
78}
79
80fn parse_shutdown(line: &str, id: &str) -> Vec<Row> {
81    let Ok(rec) = serde_json::from_str::<Value>(line) else {
82        return Vec::new();
83    };
84    if rec.get("type").and_then(Value::as_str) != Some("session.shutdown") {
85        return Vec::new();
86    }
87    let Some(ts) = rec
88        .get("timestamp")
89        .and_then(Value::as_str)
90        .and_then(parse_ts)
91    else {
92        return Vec::new();
93    };
94    let Some(metrics) = rec.pointer("/data/modelMetrics").and_then(Value::as_object) else {
95        return Vec::new();
96    };
97    metrics
98        .iter()
99        .filter_map(|(model, m)| {
100            let u = m.get("usage")?;
101            let cache_read = num(u.get("cacheReadTokens"));
102            let cache_write = num(u.get("cacheWriteTokens"));
103            let output = num(u.get("outputTokens")) + num(u.get("reasoningTokens"));
104            // Copilot's inputTokens includes the cached portions: split them out.
105            let input = num(u.get("inputTokens")).saturating_sub(cache_read + cache_write);
106            if input + output + cache_read + cache_write == 0 {
107                return None;
108            }
109            Some(Row {
110                tool: Tool::Copilot,
111                project: model.clone(),
112                id: id.to_string(),
113                ts,
114                input,
115                output,
116                cache_read,
117                cache_write,
118                cost: 0.0,
119            })
120        })
121        .collect()
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use serde_json::json;
128
129    fn shutdown() -> String {
130        json!({"type":"session.shutdown","timestamp":"2026-04-15T09:52:27.352Z","data":{"modelMetrics":{
131            "m-a":{"usage":{"inputTokens":100,"outputTokens":50,"cacheReadTokens":10,"cacheWriteTokens":20,"reasoningTokens":5}},
132            "m-b":{"usage":{"inputTokens":0,"outputTokens":0}}}}})
133        .to_string()
134    }
135
136    #[test]
137    fn splits_cache_out_of_input_and_adds_reasoning_to_output() {
138        let rows = parse_shutdown(&shutdown(), "s");
139        assert_eq!(rows.len(), 1, "the all-zero model is dropped");
140        let r = &rows[0];
141        assert_eq!(
142            (r.input, r.output, r.cache_read, r.cache_write),
143            (70, 55, 10, 20)
144        );
145        assert_eq!(r.project, "m-a");
146    }
147
148    #[test]
149    fn old_logs_without_usage_yield_nothing() {
150        // The Copilot CLI logs seen on real machines before usage tracking: only
151        // turn/tool events and `session.truncation` (context size, not usage).
152        let d = tempfile::tempdir().unwrap();
153        std::fs::write(
154            d.path().join("abc.jsonl"),
155            r#"{"type":"session.start","data":{},"timestamp":"2025-12-17T11:38:25.130Z"}
156{"type":"session.truncation","data":{"tokenLimit":128000},"timestamp":"2025-12-17T11:39:19.716Z"}
157{"type":"assistant.message","data":{"content":"hi"},"timestamp":"2025-12-17T11:39:22.000Z"}
158"#,
159        )
160        .unwrap();
161        assert!(collect_copilot_from(d.path(), DateTime::<Utc>::UNIX_EPOCH)
162            .unwrap()
163            .is_empty());
164    }
165
166    #[test]
167    fn reads_both_flat_and_nested_session_files() {
168        let d = tempfile::tempdir().unwrap();
169        std::fs::write(d.path().join("flat.jsonl"), shutdown()).unwrap();
170        std::fs::create_dir_all(d.path().join("sess-2")).unwrap();
171        std::fs::write(d.path().join("sess-2/events.jsonl"), shutdown()).unwrap();
172        let mut ids: Vec<_> = collect_copilot_from(d.path(), DateTime::<Utc>::UNIX_EPOCH)
173            .unwrap()
174            .into_iter()
175            .map(|r| r.id)
176            .collect();
177        ids.sort();
178        assert_eq!(ids, ["flat", "sess-2"]);
179    }
180
181    #[test]
182    fn garbage_is_ignored() {
183        assert!(parse_shutdown("session.shutdown but not json", "s").is_empty());
184    }
185}