tokenburn_core/features/copilot/
ops.rs1use 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
14pub 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
26static 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 .filter(|l| l.contains("session.shutdown"))
60 .flat_map(|l| parse_shutdown(&l, &id))
61 .collect()
62}
63
64fn 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 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 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}