1use super::{normalize_workspace_key, workspace_label_from_key, UnifiedMessage};
4use crate::{provider_identity, TokenBreakdown};
5use chrono::TimeZone;
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::io::{BufRead, BufReader};
9use std::path::Path;
10
11const DEFAULT_PROVIDER: &str = "tencent";
12pub(crate) const TENCENT_BUDDY_PARSER_BASE_VERSION: u32 = 1;
13
14#[derive(Debug, Deserialize)]
15struct BuddyLine {
16 id: Option<String>,
17 timestamp: Option<i64>,
18 #[serde(rename = "type")]
19 line_type: Option<String>,
20 role: Option<String>,
21 status: Option<String>,
22 #[serde(rename = "sessionId")]
23 session_id: Option<String>,
24 cwd: Option<String>,
25 message: Option<BuddyMessage>,
26 #[serde(rename = "providerData")]
27 provider_data: Option<BuddyProviderData>,
28}
29
30#[derive(Debug, Deserialize)]
31struct BuddyMessage {
32 model: Option<String>,
33 usage: Option<BuddyUsage>,
34}
35
36#[derive(Debug, Deserialize)]
37struct BuddyProviderData {
38 model: Option<String>,
39 #[serde(rename = "requestModelId")]
40 request_model_id: Option<String>,
41 #[serde(rename = "messageId")]
42 message_id: Option<String>,
43 #[serde(rename = "traceId")]
44 trace_id: Option<String>,
45 usage: Option<BuddyUsage>,
46 #[serde(rename = "rawUsage")]
47 raw_usage: Option<BuddyUsage>,
48}
49
50#[derive(Debug, Clone, Deserialize)]
51struct BuddyUsage {
52 #[serde(rename = "cachedMissTokens")]
53 cached_miss_tokens: Option<i64>,
54 #[serde(rename = "cacheMissTokens")]
55 cache_miss_tokens: Option<i64>,
56 #[serde(rename = "input_tokens")]
57 input_tokens: Option<i64>,
58 #[serde(rename = "inputTokens")]
59 input_tokens_camel: Option<i64>,
60 prompt_tokens: Option<i64>,
61 #[serde(rename = "output_tokens")]
62 output_tokens: Option<i64>,
63 #[serde(rename = "outputTokens")]
64 output_tokens_camel: Option<i64>,
65 completion_tokens: Option<i64>,
66 #[serde(rename = "cache_read_input_tokens")]
67 cache_read_input_tokens: Option<i64>,
68 #[serde(rename = "cacheReadInputTokens")]
69 cache_read_input_tokens_camel: Option<i64>,
70 #[serde(rename = "cacheTokens")]
71 cache_tokens: Option<i64>,
72 prompt_cache_hit_tokens: Option<i64>,
73 cached_tokens: Option<i64>,
74 #[serde(rename = "cache_creation_input_tokens")]
75 cache_creation_input_tokens: Option<i64>,
76 #[serde(rename = "cacheCreationInputTokens")]
77 cache_creation_input_tokens_camel: Option<i64>,
78 #[serde(rename = "cachedWriteTokens")]
79 cached_write_tokens: Option<i64>,
80 prompt_cache_write_tokens: Option<i64>,
81 #[serde(rename = "completion_thinking_tokens")]
82 completion_thinking_tokens: Option<i64>,
83 #[serde(rename = "completionThinkingTokens")]
84 completion_thinking_tokens_camel: Option<i64>,
85 #[serde(rename = "reasoningTokens")]
86 reasoning_tokens: Option<i64>,
87}
88
89impl BuddyUsage {
90 fn to_breakdown(&self) -> Option<TokenBreakdown> {
91 let tokens = TokenBreakdown {
92 input: first_present(&[
93 self.cached_miss_tokens,
94 self.cache_miss_tokens,
95 self.input_tokens,
96 self.input_tokens_camel,
97 self.prompt_tokens,
98 ]),
99 output: first_present(&[
100 self.output_tokens,
101 self.output_tokens_camel,
102 self.completion_tokens,
103 ]),
104 cache_read: first_positive(&[
105 self.cache_read_input_tokens,
106 self.cache_read_input_tokens_camel,
107 self.cache_tokens,
108 self.prompt_cache_hit_tokens,
109 self.cached_tokens,
110 ]),
111 cache_write: first_positive(&[
112 self.cache_creation_input_tokens,
113 self.cache_creation_input_tokens_camel,
114 self.cached_write_tokens,
115 self.prompt_cache_write_tokens,
116 ]),
117 reasoning: first_present(&[
118 self.completion_thinking_tokens,
119 self.completion_thinking_tokens_camel,
120 self.reasoning_tokens,
121 ]),
122 };
123
124 (tokens.total() > 0).then_some(tokens)
125 }
126}
127
128fn first_present(values: &[Option<i64>]) -> i64 {
129 values.iter().copied().flatten().next().unwrap_or(0).max(0)
130}
131
132fn first_positive(values: &[Option<i64>]) -> i64 {
133 values
134 .iter()
135 .copied()
136 .flatten()
137 .find(|count| *count > 0)
138 .or_else(|| values.iter().copied().flatten().next())
139 .unwrap_or(0)
140 .max(0)
141}
142
143pub(crate) fn parse_jsonl_file(
144 client: &'static str,
145 default_model: &'static str,
146 path: &Path,
147) -> Vec<UnifiedMessage> {
148 let file = match std::fs::File::open(path) {
149 Ok(file) => file,
150 Err(_) => return Vec::new(),
151 };
152
153 let fallback_session_id = path
154 .file_stem()
155 .and_then(|name| name.to_str())
156 .unwrap_or("unknown")
157 .to_string();
158 let fallback_timestamp = super::utils::file_modified_timestamp_ms(path);
159 let mut keyed_indices: HashMap<String, usize> = HashMap::new();
160 let mut messages: Vec<UnifiedMessage> = Vec::new();
161
162 for line in BufReader::new(file).lines() {
163 let Ok(line) = line else {
164 continue;
165 };
166 let trimmed = line.trim();
167 if trimmed.is_empty() {
168 continue;
169 }
170
171 let mut bytes = trimmed.as_bytes().to_vec();
172 let item = match simd_json::from_slice::<BuddyLine>(&mut bytes) {
173 Ok(item) => item,
174 Err(_) => continue,
175 };
176
177 let is_assistant_message = item.line_type.as_deref() == Some("message")
178 && item.role.as_deref() == Some("assistant");
179 let is_function_call = item.line_type.as_deref() == Some("function_call");
180 if !is_assistant_message && !is_function_call {
181 continue;
182 }
183
184 if item
185 .status
186 .as_deref()
187 .is_some_and(|status| status != "completed")
188 {
189 continue;
190 }
191
192 let usage = item
193 .message
194 .as_ref()
195 .and_then(|message| message.usage.as_ref())
196 .or_else(|| {
197 item.provider_data
198 .as_ref()
199 .and_then(|provider| provider.usage.as_ref())
200 })
201 .or_else(|| {
202 item.provider_data
203 .as_ref()
204 .and_then(|provider| provider.raw_usage.as_ref())
205 });
206 let Some(tokens) = usage.and_then(BuddyUsage::to_breakdown) else {
207 continue;
208 };
209
210 let provider_data = item.provider_data.as_ref();
211 let model_id = provider_data
212 .and_then(|provider| provider.model.as_deref())
213 .or_else(|| provider_data.and_then(|provider| provider.request_model_id.as_deref()))
214 .or_else(|| {
215 item.message
216 .as_ref()
217 .and_then(|message| message.model.as_deref())
218 })
219 .filter(|model| !model.trim().is_empty())
220 .unwrap_or(default_model)
221 .to_string();
222 let provider_id = provider_identity::inferred_provider_from_model(&model_id)
223 .unwrap_or(DEFAULT_PROVIDER)
224 .to_string();
225 let session_id = item
226 .session_id
227 .unwrap_or_else(|| fallback_session_id.clone());
228 let timestamp = item.timestamp.unwrap_or(fallback_timestamp);
229
230 let mut message = UnifiedMessage::new(
231 client,
232 model_id,
233 provider_id,
234 session_id.clone(),
235 timestamp,
236 tokens,
237 0.0,
238 );
239
240 if let Some(workspace_key) = item.cwd.as_deref().and_then(normalize_workspace_key) {
241 let workspace_label = workspace_label_from_key(&workspace_key);
242 message.set_workspace(Some(workspace_key), workspace_label);
243 }
244
245 let dedup_key = provider_data
246 .and_then(|provider| provider.message_id.as_deref())
247 .or_else(|| provider_data.and_then(|provider| provider.trace_id.as_deref()))
248 .or(item.id.as_deref())
249 .map(|key| format!("{client}:{session_id}:{key}"));
250 message.dedup_key = dedup_key.clone();
251
252 if let Some(key) = dedup_key {
253 if let Some(existing_index) = keyed_indices.get(&key).copied() {
254 if message.tokens.total() >= messages[existing_index].tokens.total() {
255 messages[existing_index] = message;
256 }
257 continue;
258 }
259 keyed_indices.insert(key, messages.len());
260 }
261
262 messages.push(message);
263 }
264
265 messages
266}
267
268pub(crate) fn parse_extension_log_file(
269 client: &'static str,
270 default_model: &'static str,
271 path: &Path,
272) -> Vec<UnifiedMessage> {
273 let file = match std::fs::File::open(path) {
274 Ok(file) => file,
275 Err(_) => return Vec::new(),
276 };
277
278 let fallback_timestamp = super::utils::file_modified_timestamp_ms(path);
279 let fallback_session_id = path
280 .file_stem()
281 .and_then(|name| name.to_str())
282 .unwrap_or("extension-log")
283 .to_string();
284 let mut models_by_agent: HashMap<String, String> = HashMap::new();
285 let mut messages = Vec::new();
286
287 for line in BufReader::new(file).lines() {
288 let Ok(line) = line else {
289 continue;
290 };
291
292 if line.contains("[CraftInvokableAgent]") && line.contains("Model prepared:") {
293 if let Some((agent_id, model_id)) = parse_model_prepared_line(&line) {
294 models_by_agent.insert(agent_id, model_id);
295 }
296 continue;
297 }
298
299 if !line.contains("[AgentReporter]")
300 || !line.contains("Agent execution successful with usage:")
301 {
302 continue;
303 }
304
305 let Some(agent_id) = bracket_value_after(&line, "[AgentReporter]") else {
306 continue;
307 };
308 let Some(usage_json) = line.split("Agent execution successful with usage:").nth(1) else {
309 continue;
310 };
311 let usage_json = usage_json.trim();
312 let Some(json_end) = usage_json.rfind('}') else {
313 continue;
314 };
315 let mut bytes = usage_json.as_bytes()[..=json_end].to_vec();
316 let usage = match simd_json::from_slice::<BuddyUsage>(&mut bytes) {
317 Ok(usage) => usage,
318 Err(_) => continue,
319 };
320 let Some(tokens) = usage.to_breakdown() else {
321 continue;
322 };
323
324 let timestamp = parse_log_timestamp_ms(&line).unwrap_or(fallback_timestamp);
325 let model_id = models_by_agent
326 .get(&agent_id)
327 .cloned()
328 .unwrap_or_else(|| default_model.to_string());
329 let provider_id = provider_identity::inferred_provider_from_model(&model_id)
330 .unwrap_or(DEFAULT_PROVIDER)
331 .to_string();
332 let mut message = UnifiedMessage::new(
333 client,
334 model_id,
335 provider_id,
336 agent_id.clone(),
337 timestamp,
338 tokens,
339 0.0,
340 );
341 let dedup_second = timestamp.div_euclid(1000);
349 message.dedup_key = Some(format!(
350 "{client}:extension-log:{agent_id}:{dedup_second}:{}:{}:{}:{}:{}",
351 message.tokens.input,
352 message.tokens.output,
353 message.tokens.cache_read,
354 message.tokens.cache_write,
355 message.tokens.reasoning
356 ));
357
358 if let Some(workspace_key) = workspace_from_log_path(path) {
359 let workspace_label = workspace_label_from_key(&workspace_key);
360 message.set_workspace(Some(workspace_key), workspace_label);
361 }
362 if message.session_id.is_empty() {
363 message.session_id = fallback_session_id.clone();
364 }
365
366 messages.push(message);
367 }
368
369 messages
370}
371
372pub(crate) fn is_jsonl_source(path: &Path) -> bool {
373 path.extension()
374 .and_then(|ext| ext.to_str())
375 .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
376}
377
378pub(crate) fn is_extension_log_source(path: &Path) -> bool {
379 path.extension()
380 .and_then(|ext| ext.to_str())
381 .is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
382}
383
384fn parse_model_prepared_line(line: &str) -> Option<(String, String)> {
385 let agent_id = bracket_value_after(line, "[CraftInvokableAgent]")?;
386 let marker = "Model prepared:";
387 let after_marker = line.split(marker).nth(1)?.trim();
388 let model_id = after_marker
389 .rsplit_once('(')
390 .and_then(|(_, tail)| tail.split_once(')').map(|(model, _)| model.trim()))
391 .filter(|model| !model.is_empty())
392 .unwrap_or(after_marker)
393 .to_string();
394 Some((agent_id, model_id))
395}
396
397fn bracket_value_after(line: &str, marker: &str) -> Option<String> {
398 let after_marker = line.split(marker).nth(1)?;
399 let start = after_marker.find('[')?;
400 let after_open = &after_marker[start + 1..];
401 let end = after_open.find(']')?;
402 let value = after_open[..end].trim();
403 (!value.is_empty()).then(|| value.to_string())
404}
405
406fn parse_log_timestamp_ms(line: &str) -> Option<i64> {
407 let raw = if let Some(rest) = line.strip_prefix('[') {
408 rest.split_once(']')?.0.trim()
409 } else {
410 line.split_once(" [")
411 .map(|(timestamp, _)| timestamp.trim())
412 .unwrap_or(line.trim())
413 };
414
415 let (date, time) = raw.split_once(' ')?;
416 let separator = if date.contains('/') { '/' } else { '-' };
417 let parts = date
418 .split(separator)
419 .filter_map(|part| part.parse::<u32>().ok())
420 .collect::<Vec<_>>();
421 if parts.len() != 3 {
422 return super::utils::parse_timestamp_str(raw);
423 }
424
425 let normalized = format!("{:04}-{:02}-{:02} {}", parts[0], parts[1], parts[2], time);
426 parse_local_naive_timestamp_ms(&normalized)
427 .or_else(|| super::utils::parse_timestamp_str(&normalized))
428}
429
430fn parse_local_naive_timestamp_ms(value: &str) -> Option<i64> {
431 for format in ["%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%d %H:%M:%S"] {
432 if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(value, format) {
433 return match chrono::Local.from_local_datetime(&naive) {
434 chrono::LocalResult::Single(dt) => Some(dt.timestamp_millis()),
435 chrono::LocalResult::Ambiguous(earlier, _) => Some(earlier.timestamp_millis()),
436 chrono::LocalResult::None => None,
437 };
438 }
439 }
440
441 None
442}
443
444fn workspace_from_log_path(path: &Path) -> Option<String> {
445 let stem = path.file_stem()?.to_str()?;
446 let workspace = stem.split("__").next().unwrap_or(stem);
447 normalize_workspace_key(workspace)
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
455 fn parse_jsonl_file_reads_assistant_usage() {
456 let dir = tempfile::tempdir().unwrap();
457 let project_dir = dir.path().join("projects").join("c-Users-alice-repo");
458 std::fs::create_dir_all(&project_dir).unwrap();
459 let path = project_dir.join("session-1.jsonl");
460 std::fs::write(
461 &path,
462 r#"{"id":"user-1","timestamp":1780000000000,"type":"message","role":"user","sessionId":"session-1","cwd":"/Users/alice/repo"}
463{"id":"assistant-1","timestamp":1780000000100,"type":"message","role":"assistant","status":"completed","sessionId":"session-1","cwd":"/Users/alice/repo","providerData":{"model":"glm-5.2","messageId":"msg-1"},"message":{"usage":{"input_tokens":24486,"output_tokens":3,"total_tokens":24489,"cache_read_input_tokens":14720}}}"#,
464 )
465 .unwrap();
466
467 let messages = parse_jsonl_file("codebuddy", "codebuddy", &path);
468
469 assert_eq!(messages.len(), 1);
470 let message = &messages[0];
471 assert_eq!(message.client, "codebuddy");
472 assert_eq!(message.model_id, "glm-5.2");
473 assert_eq!(message.provider_id, "zai");
480 assert_eq!(message.session_id, "session-1");
481 assert_eq!(message.tokens.input, 24486);
482 assert_eq!(message.tokens.output, 3);
483 assert_eq!(message.tokens.cache_read, 14720);
484 assert_eq!(message.workspace_label.as_deref(), Some("repo"));
485 assert_eq!(
486 message.dedup_key.as_deref(),
487 Some("codebuddy:session-1:msg-1")
488 );
489 }
490
491 #[test]
492 fn parse_jsonl_file_reads_function_call_usage() {
493 let dir = tempfile::tempdir().unwrap();
494 let path = dir.path().join("session-2.jsonl");
495 std::fs::write(
496 &path,
497 r#"{"id":"call-1","timestamp":1780000000100,"type":"function_call","sessionId":"session-2","providerData":{"requestModelId":"glm-5.2","messageId":"msg-2","rawUsage":{"prompt_tokens":10,"completion_tokens":2,"prompt_cache_hit_tokens":3,"prompt_cache_write_tokens":4,"completion_thinking_tokens":5}}}"#,
498 )
499 .unwrap();
500
501 let messages = parse_jsonl_file("workbuddy", "workbuddy", &path);
502
503 assert_eq!(messages.len(), 1);
504 assert_eq!(messages[0].client, "workbuddy");
505 assert_eq!(messages[0].tokens.input, 10);
506 assert_eq!(messages[0].tokens.output, 2);
507 assert_eq!(messages[0].tokens.cache_read, 3);
508 assert_eq!(messages[0].tokens.cache_write, 4);
509 assert_eq!(messages[0].tokens.reasoning, 5);
510 }
511
512 #[test]
513 fn parse_extension_log_file_splits_cached_miss_and_cache_tokens() {
514 let dir = tempfile::tempdir().unwrap();
515 let path = dir.path().join("moza-configurator__session.log");
516 std::fs::write(
517 &path,
518 r#"[2026/7/1 16:56:01.100] [info] [CraftInvokableAgent] [agent-1] Model prepared: Kimi-K2.7-Code (kimi-k2.7)
519[2026/7/1 16:56:02.200] [info] [AgentReporter] [agent-1] Agent execution successful with usage: {"inputTokens":140732,"outputTokens":635,"totalTokens":141367,"cacheTokens":76032,"cachedWriteTokens":0,"cachedMissTokens":64700}"#,
520 )
521 .unwrap();
522
523 let messages = parse_extension_log_file("codebuddy", "codebuddy", &path);
524
525 assert_eq!(messages.len(), 1);
526 assert_eq!(messages[0].model_id, "kimi-k2.7");
527 assert_eq!(messages[0].tokens.input, 64700);
528 assert_eq!(messages[0].tokens.output, 635);
529 assert_eq!(messages[0].tokens.cache_read, 76032);
530 assert_eq!(messages[0].tokens.total(), 141367);
531 assert_eq!(
532 messages[0].workspace_label.as_deref(),
533 Some("moza-configurator")
534 );
535 }
536
537 #[test]
538 fn parse_extension_log_file_keeps_repeated_agent_usage_at_different_times() {
539 let dir = tempfile::tempdir().unwrap();
540 let path = dir.path().join("session.log");
541 std::fs::write(
542 &path,
543 r#"[2026/7/1 16:56:01.100] [info] [CraftInvokableAgent] [agent-1] Model prepared: GLM-5.2 (glm-5.2)
544[2026/7/1 16:56:02.200] [info] [AgentReporter] [agent-1] Agent execution successful with usage: {"inputTokens":10,"outputTokens":2,"totalTokens":12}
545[2026/7/1 16:57:02.200] [info] [AgentReporter] [agent-1] Agent execution successful with usage: {"inputTokens":10,"outputTokens":2,"totalTokens":12}"#,
546 )
547 .unwrap();
548
549 let messages = parse_extension_log_file("codebuddy", "codebuddy", &path);
550
551 assert_eq!(messages.len(), 2);
552 assert_ne!(messages[0].dedup_key, messages[1].dedup_key);
553 }
554
555 #[test]
556 fn parse_extension_log_file_mirrored_sinks_share_dedup_key_despite_ms_skew() {
557 let dir = tempfile::tempdir().unwrap();
562
563 let extension_sink = dir.path().join("proj__session.log");
564 std::fs::write(
565 &extension_sink,
566 r#"[2026/7/1 16:56:02.200] [info] [AgentReporter] [agent-1] Agent execution successful with usage: {"inputTokens":140732,"outputTokens":635,"totalTokens":141367}"#,
567 )
568 .unwrap();
569
570 let host_sink = dir.path().join("proj__host.log");
571 std::fs::write(
572 &host_sink,
573 r#"2026-07-01 16:56:02.201 [info] [AgentReporter] [agent-1] Agent execution successful with usage: {"inputTokens":140732,"outputTokens":635,"totalTokens":141367}"#,
574 )
575 .unwrap();
576
577 let from_extension = parse_extension_log_file("codebuddy", "codebuddy", &extension_sink);
578 let from_host = parse_extension_log_file("codebuddy", "codebuddy", &host_sink);
579
580 assert_eq!(from_extension.len(), 1);
581 assert_eq!(from_host.len(), 1);
582 assert!(from_extension[0].dedup_key.is_some());
583 assert_eq!(from_extension[0].dedup_key, from_host[0].dedup_key);
584 }
585}