1use serde_json::Value;
7use sha2::{Digest, Sha256};
8use std::collections::{BTreeMap, BTreeSet, HashSet};
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use crate::types::{
14 AgentSession, LlmResponse, SessionCandidate, SessionDirStat, SessionEvents, TokenUsage,
15 ToolEvent, UserPrompt,
16};
17use crate::{AGENT_CLAUDE, AGENT_CODEX, AGENT_GEMINI};
18
19pub fn discover_session_files() -> Vec<SessionCandidate> {
21 user_home_dir()
22 .as_deref()
23 .map(discover_session_files_in_home)
24 .unwrap_or_default()
25}
26
27pub fn discover_session_files_in_home(home: &Path) -> Vec<SessionCandidate> {
29 let roots = [
30 (AGENT_CLAUDE, home.join(".claude/projects")),
31 (AGENT_CODEX, home.join(".codex/sessions")),
32 (AGENT_GEMINI, home.join(".gemini/tmp")),
33 ];
34 let mut out = Vec::new();
35 for (agent, dir) in roots {
36 walk_agent_files(agent, &dir, &mut |path, meta| {
37 out.push(SessionCandidate {
38 agent,
39 path: path.to_path_buf(),
40 updated: meta.modified().unwrap_or(UNIX_EPOCH),
41 });
42 });
43 }
44 out
45}
46
47pub fn discover_session_files_in_dir(agent: &'static str, dir: &Path) -> Vec<SessionCandidate> {
48 let mut out = Vec::new();
49 walk_agent_files(agent, dir, &mut |path, meta| {
50 out.push(SessionCandidate {
51 agent,
52 path: path.to_path_buf(),
53 updated: meta.modified().unwrap_or(UNIX_EPOCH),
54 });
55 });
56 out
57}
58
59pub fn count_session_dirs() -> Vec<SessionDirStat> {
61 let Some(home) = user_home_dir() else {
62 return Vec::new();
63 };
64 [
65 (AGENT_CLAUDE, home.join(".claude/projects")),
66 (AGENT_CODEX, home.join(".codex/sessions")),
67 (AGENT_GEMINI, home.join(".gemini/tmp")),
68 ]
69 .into_iter()
70 .filter_map(|(agent, dir)| {
71 let (mut sessions, mut bytes) = (0usize, 0u64);
72 walk_agent_files(agent, &dir, &mut |_, meta| {
73 sessions += 1;
74 bytes += meta.len();
75 });
76 (sessions > 0).then_some(SessionDirStat {
77 agent,
78 dir,
79 sessions,
80 bytes,
81 })
82 })
83 .collect()
84}
85
86pub fn session_candidate_from_path(path: &Path) -> Option<SessionCandidate> {
87 let agent = agent_source_for_path(path).or_else(|| loose_agent_source_for_path(path))?;
88 let updated = fs::metadata(path)
89 .and_then(|metadata| metadata.modified())
90 .unwrap_or(UNIX_EPOCH);
91 Some(SessionCandidate {
92 agent,
93 path: path.to_path_buf(),
94 updated,
95 })
96}
97
98pub fn parse_session_file(candidate: &SessionCandidate) -> Option<AgentSession> {
100 let content = fs::read_to_string(&candidate.path).ok()?;
101 parse_session_content(
102 candidate.agent,
103 &candidate.path,
104 candidate.updated,
105 &content,
106 )
107}
108
109pub fn parse_session_path(path: &Path) -> Option<AgentSession> {
111 parse_session_file(&session_candidate_from_path(path)?)
112}
113
114pub fn parse_session_content(
116 agent: &str,
117 path: &Path,
118 updated: SystemTime,
119 content: &str,
120) -> Option<AgentSession> {
121 parse_session_impl(agent, path, updated, content)
122}
123
124fn parse_session_impl(
125 agent: &str,
126 path: &Path,
127 updated: SystemTime,
128 content: &str,
129) -> Option<AgentSession> {
130 if agent == AGENT_GEMINI {
131 parse_gemini_json(path, updated, content)
132 } else {
133 parse_jsonl(agent, path, updated, content)
134 }
135}
136
137pub fn session_log_path_from_str(raw: &str) -> Option<PathBuf> {
139 let trimmed = raw.trim().trim_end_matches(" (deleted)");
140 if trimmed.is_empty() {
141 return None;
142 }
143 let path = Path::new(trimmed);
144 if !path.is_absolute() || !is_agent_session_file(path) {
145 return None;
146 }
147 agent_source_for_path(path).map(|_| normalize_session_log_path(path))
148}
149
150pub fn normalize_session_log_path(path: &Path) -> PathBuf {
152 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
153}
154
155pub fn agent_source_for_path(path: &Path) -> Option<&'static str> {
157 let value = path.to_string_lossy();
158 if value.contains("/.claude/") && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
159 {
160 Some(AGENT_CLAUDE)
161 } else if value.contains("/.codex/")
162 && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
163 {
164 Some(AGENT_CODEX)
165 } else if value.contains("/.gemini/")
166 && path.extension().and_then(|ext| ext.to_str()) == Some("json")
167 {
168 Some(AGENT_GEMINI)
169 } else {
170 None
171 }
172}
173
174fn loose_agent_source_for_path(path: &Path) -> Option<&'static str> {
175 let value = path.to_string_lossy();
176 if value.contains("/codex/") && value.contains("sessions") {
177 Some(AGENT_CODEX)
178 } else if value.contains("/claude/") && value.contains("projects") {
179 Some(AGENT_CLAUDE)
180 } else {
181 None
182 }
183}
184
185pub fn fixture_session_path(agent: &str, home: &Path) -> Option<PathBuf> {
187 match agent {
188 AGENT_CLAUDE => Some(home.join(".claude/projects/test/session.jsonl")),
189 AGENT_CODEX => Some(home.join(".codex/sessions/2026/06/02/session.jsonl")),
190 AGENT_GEMINI => Some(home.join(".gemini/tmp/test/chats/session-test.json")),
191 _ => None,
192 }
193}
194
195pub fn is_codex_cli_entrypoint(target: Option<&str>) -> bool {
197 target.is_some_and(|target| {
198 Path::new(target).file_name().and_then(|name| name.to_str()) == Some("codex")
199 && !target.contains("/node_modules/")
200 })
201}
202
203pub fn codex_exec_prompt(command: &str) -> Option<String> {
205 let args = shell_words(command.split_once(" exec ")?.1.trim())?;
206 let mut index = 0usize;
207 while index < args.len() {
208 let arg = args[index].as_str();
209 if arg == "--" {
210 index += 1;
211 break;
212 }
213 if !arg.starts_with('-') {
214 break;
215 }
216 let consumed = codex_exec_option_arity(arg)?;
217 index += consumed;
218 }
219 (index < args.len())
220 .then(|| args[index..].join(" "))
221 .and_then(|prompt| clean_prompt_text(&prompt))
222}
223
224fn parse_jsonl(
229 agent: &str,
230 path: &Path,
231 updated: SystemTime,
232 content: &str,
233) -> Option<AgentSession> {
234 let mut acc = SessionAccumulator::new(agent, path, updated);
235 let mut codex_model = String::new();
236 let mut claude_message_models = BTreeMap::<String, TokenUsage>::new();
237 let mut claude_seen_usage = HashSet::new();
238 let mut events = SessionEvents::default();
239 let mut current_prompt_index = 0usize;
240 let mut call_index = BTreeMap::<String, usize>::new();
241
242 for line in content.lines() {
243 let Ok(obj) = serde_json::from_str::<Value>(line) else {
244 continue;
245 };
246 let (session_id, conversation_id) = local_session_ids(&obj);
247 if let Some(id) = session_id {
248 acc.session_id = id;
249 }
250 if let Some(id) = conversation_id {
251 acc.conversation_id = Some(id);
252 }
253 if acc.cwd.is_none() {
254 acc.cwd = obj
255 .get("cwd")
256 .and_then(Value::as_str)
257 .or_else(|| obj.pointer("/payload/cwd").and_then(Value::as_str))
258 .filter(|s| !s.is_empty())
259 .map(ToString::to_string);
260 }
261 if let Some(ts) = obj.get("timestamp").and_then(Value::as_str) {
262 acc.last_message_at = Some(ts.to_string());
263 acc.end_timestamp_ms = iso_ms(ts).or(acc.end_timestamp_ms);
264 }
265 let typ = obj.get("type").and_then(Value::as_str).unwrap_or("");
266 match (agent, typ) {
267 (AGENT_CLAUDE, "result") => {
268 acc.duration_ms = json_u64(&obj, "duration_ms");
269 if let Some(model_usage) = obj.get("modelUsage").and_then(Value::as_object) {
270 for (name, usage) in model_usage {
271 acc.model.get_or_insert_with(|| name.clone());
272 acc.add_usage(
273 name,
274 json_i64(usage, "inputTokens"),
275 json_i64(usage, "outputTokens"),
276 json_i64(usage, "cacheCreationInputTokens"),
277 json_i64(usage, "cacheReadInputTokens"),
278 0,
279 );
280 }
281 }
282 }
283 (AGENT_CLAUDE, "assistant") => {
284 if let Some(name) = obj.pointer("/message/model").and_then(Value::as_str) {
285 acc.model.get_or_insert_with(|| name.to_string());
286 }
287 let model = obj
288 .pointer("/message/model")
289 .and_then(Value::as_str)
290 .or(acc.model.as_deref())
291 .unwrap_or(AGENT_CLAUDE)
292 .to_string();
293 if let Some(usage) = obj.pointer("/message/usage")
294 && claude_seen_usage.insert(claude_usage_key(&obj))
295 {
296 let name = obj
297 .pointer("/message/model")
298 .and_then(Value::as_str)
299 .unwrap_or("unknown");
300 add_usage(
301 &mut claude_message_models,
302 name,
303 json_i64(usage, "input_tokens"),
304 json_i64(usage, "output_tokens"),
305 json_i64(usage, "cache_creation_input_tokens"),
306 json_i64(usage, "cache_read_input_tokens"),
307 0,
308 );
309 }
310 let content = obj.pointer("/message/content").unwrap_or(&Value::Null);
311 if let Some(items) = content.as_array() {
312 for item in items
313 .iter()
314 .filter(|item| item.get("type").and_then(Value::as_str) == Some("tool_use"))
315 {
316 let name = item.get("name").and_then(Value::as_str).unwrap_or("?");
317 acc.add_tool(name);
318 if let Some(fp) = item
319 .pointer("/input/file_path")
320 .and_then(Value::as_str)
321 .filter(|s| !is_noise_path(s))
322 {
323 acc.add_file(fp);
324 }
325 let call_id = item.get("id").and_then(Value::as_str).map(str::to_string);
326 let event = tool_event_from_input(
327 acc.cwd.as_deref(),
328 ts_ms_from_event(&obj),
329 current_prompt_index,
330 name,
331 item.get("input").unwrap_or(&Value::Null),
332 call_id.clone(),
333 );
334 if let Some(id) = call_id {
335 call_index.insert(id, events.tools.len());
336 }
337 events.tools.push(event);
338 }
339 }
340 let text = content_to_text(content);
341 let usage = obj.pointer("/message/usage").unwrap_or(&Value::Null);
342 if !text.trim().is_empty() || usage.is_object() {
343 let preview_text = if !text.trim().is_empty() {
345 text.clone()
346 } else if let Some(items) = content.as_array() {
347 let tool_names: Vec<_> = items
348 .iter()
349 .filter_map(|item| {
350 if item.get("type").and_then(Value::as_str) == Some("tool_use") {
351 item.get("name").and_then(Value::as_str)
352 } else {
353 None
354 }
355 })
356 .collect();
357 if tool_names.is_empty() {
358 String::new()
359 } else {
360 format!("tool: {}", tool_names.join(", "))
361 }
362 } else {
363 String::new()
364 };
365 events.llm_responses.push(LlmResponse {
366 ts_ms: ts_ms_from_event(&obj),
367 prompt_index: current_prompt_index,
368 model,
369 text_hash: short_hash(&(text.clone() + &usage.to_string()), 12),
370 preview: truncate_clean(
371 if preview_text.is_empty() {
372 "token report"
373 } else {
374 &preview_text
375 },
376 140,
377 ),
378 input_tokens: json_u64(usage, "input_tokens"),
379 output_tokens: json_u64(usage, "output_tokens"),
380 cache_tokens: json_u64(usage, "cache_creation_input_tokens")
381 + json_u64(usage, "cache_read_input_tokens"),
382 total_tokens: 0,
383 tag: String::new(),
384 });
385 }
386 }
387 (AGENT_CLAUDE, "queue-operation") if acc.prompt_preview.is_none() => {
388 if obj.get("operation").and_then(Value::as_str) == Some("enqueue")
389 && let Some(text) = obj.get("content").and_then(Value::as_str)
390 && let Some(text) = clean_prompt_text(text)
391 {
392 acc.prompt_preview = Some(text.clone());
393 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
394 }
395 }
396 (AGENT_CLAUDE, "last-prompt") if acc.prompt_preview.is_none() => {
397 if let Some(text) = obj.get("lastPrompt").and_then(Value::as_str)
398 && let Some(text) = clean_prompt_text(text)
399 {
400 acc.prompt_preview = Some(text.clone());
401 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
402 }
403 }
404 (AGENT_CLAUDE, "user") => {
405 let content = obj.pointer("/message/content").unwrap_or(&Value::Null);
406 if claude_is_tool_result(content) || is_claude_tool_result(&obj) {
407 let is_error = obj
408 .get("toolUseResult")
409 .and_then(|v| v.get("is_error"))
410 .and_then(Value::as_bool)
411 .unwrap_or(false);
412 for id in claude_tool_result_ids(content) {
413 if let Some(index) = call_index.get(&id).copied()
414 && let Some(tool) = events.tools.get_mut(index)
415 {
416 tool.status = if is_error { "fail" } else { "ok" }.to_string();
417 }
418 }
419 } else if let Some(text) = local_message_preview(content) {
420 if acc.prompt_preview.is_none() {
421 acc.prompt_preview = Some(text.clone());
422 }
423 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
424 }
425 }
426 (AGENT_CLAUDE, "last-prompt") => {
427 if let Some(text) = obj.get("lastPrompt").and_then(Value::as_str)
428 && let Some(text) = clean_prompt_text(text)
429 {
430 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
431 }
432 }
433 (AGENT_CODEX, "turn_context") => {
434 if let Some(name) = obj.pointer("/payload/model").and_then(Value::as_str) {
435 codex_model = name.to_string();
436 acc.model = Some(name.to_string());
437 }
438 }
439 (AGENT_CODEX, "event_msg") => {
440 let payload = obj.get("payload").unwrap_or(&Value::Null);
441 let ptype = payload.get("type").and_then(Value::as_str).unwrap_or("");
442 if ptype == "token_count"
443 && let Some(usage) = payload.pointer("/info/total_token_usage")
444 {
445 let name = if codex_model.is_empty() {
446 "unknown"
447 } else {
448 &codex_model
449 };
450 let raw_input = json_i64(usage, "input_tokens").max(0);
451 let output = json_i64(usage, "output_tokens").max(0);
452 let cache = json_i64(usage, "cached_input_tokens").max(0);
453 let input = codex_uncached_input(raw_input, cache);
454 acc.set_usage(name, input, output, 0, cache, input + output);
455 }
456 if matches!(ptype, "token_count" | "token_usage") {
457 let info = payload
458 .get("info")
459 .or_else(|| payload.get("usage"))
460 .unwrap_or(payload);
461 let total_usage = info.get("total_token_usage");
462 let token_usage = info.get("last_token_usage").or(total_usage).unwrap_or(info);
463 let is_cumulative_usage =
464 total_usage.is_some() && info.get("last_token_usage").is_none();
465 let raw_input_tokens = json_u64(token_usage, "input_tokens");
466 let output_tokens = json_u64(token_usage, "output_tokens");
467 let cache_tokens = json_u64(token_usage, "cached_input_tokens");
468 let uncached_input_tokens = raw_input_tokens.saturating_sub(cache_tokens);
469 let input_tokens = if is_cumulative_usage {
470 uncached_input_tokens
471 } else {
472 raw_input_tokens
473 };
474 let raw_total_tokens = json_u64(token_usage, "total_tokens")
475 .max(json_u64(info, "total_tokens"))
476 .max(json_u64(info, "tokens"));
477 let total_tokens = if is_cumulative_usage {
478 uncached_input_tokens + output_tokens
479 } else {
480 raw_total_tokens
481 };
482 if total_tokens > 0 {
483 if let Some(last) = events.llm_responses.last_mut()
484 && last.total_tokens == 0
485 {
486 last.input_tokens = input_tokens;
487 last.output_tokens = output_tokens;
488 last.cache_tokens = cache_tokens;
489 last.total_tokens = total_tokens;
490 continue;
491 }
492 events.llm_responses.push(LlmResponse {
493 ts_ms: ts_ms_from_event(&obj),
494 prompt_index: current_prompt_index,
495 model: if codex_model.is_empty() {
496 AGENT_CODEX.to_string()
497 } else {
498 codex_model.clone()
499 },
500 text_hash: short_hash(&token_usage.to_string(), 12),
501 preview: "token report".to_string(),
502 input_tokens,
503 output_tokens,
504 cache_tokens,
505 total_tokens,
506 tag: String::new(),
507 });
508 }
509 }
510 if ptype == "user_message" {
511 let text = payload
512 .get("message")
513 .or_else(|| payload.get("content"))
514 .and_then(Value::as_str)
515 .unwrap_or("");
516 if let Some(text) = clean_prompt_text(text) {
517 acc.prompt_preview = Some(text.clone());
518 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
519 }
520 }
521 if ptype == "agent_message" {
522 let text = payload
523 .get("message")
524 .or_else(|| payload.get("content"))
525 .and_then(Value::as_str)
526 .unwrap_or("");
527 if let Some(text) = clean_prompt_text(text) {
528 events.llm_responses.push(LlmResponse {
529 ts_ms: ts_ms_from_event(&obj),
530 prompt_index: current_prompt_index,
531 model: if codex_model.is_empty() {
532 AGENT_CODEX.to_string()
533 } else {
534 codex_model.clone()
535 },
536 text_hash: short_hash(&text, 12),
537 preview: truncate_clean(&text, 180),
538 input_tokens: 0,
539 output_tokens: 0,
540 cache_tokens: 0,
541 total_tokens: 0,
542 tag: String::new(),
543 });
544 }
545 }
546 }
547 (AGENT_CODEX, "response_item")
548 if obj.pointer("/payload/type").and_then(Value::as_str)
549 == Some("function_call") =>
550 {
551 let name = obj
552 .pointer("/payload/name")
553 .and_then(Value::as_str)
554 .unwrap_or("?");
555 acc.add_tool(name);
556 let payload = obj.get("payload").unwrap_or(&Value::Null);
557 let args = parse_tool_args(payload.get("arguments").unwrap_or(&Value::Null));
558 let call_id = payload
559 .get("call_id")
560 .and_then(Value::as_str)
561 .map(str::to_string);
562 let event = tool_event_from_input(
563 acc.cwd.as_deref(),
564 ts_ms_from_event(&obj),
565 current_prompt_index,
566 name,
567 &args,
568 call_id.clone(),
569 );
570 if let Some(id) = call_id {
571 call_index.insert(id, events.tools.len());
572 }
573 events.tools.push(event);
574 }
575 (AGENT_CODEX, "response_item")
576 if obj.pointer("/payload/type").and_then(Value::as_str)
577 == Some("function_call_output") =>
578 {
579 if let Some(call_id) = obj.pointer("/payload/call_id").and_then(Value::as_str)
580 && let Some(index) = call_index.get(call_id).copied()
581 && let Some(tool) = events.tools.get_mut(index)
582 {
583 let output = obj
584 .pointer("/payload/output")
585 .and_then(Value::as_str)
586 .unwrap_or("");
587 tool.status = status_from_output(output).to_string();
588 }
589 }
590 (AGENT_CODEX, "response_item")
591 if obj.pointer("/payload/type").and_then(Value::as_str) == Some("message") =>
592 {
593 let payload = obj.get("payload").unwrap_or(&Value::Null);
594 let text = payload
595 .get("message")
596 .or_else(|| payload.get("content"))
597 .and_then(Value::as_str)
598 .unwrap_or("");
599 if let Some(text) = clean_prompt_text(text) {
600 events.llm_responses.push(LlmResponse {
601 ts_ms: ts_ms_from_event(&obj),
602 prompt_index: current_prompt_index,
603 model: if codex_model.is_empty() {
604 AGENT_CODEX.to_string()
605 } else {
606 codex_model.clone()
607 },
608 text_hash: short_hash(&text, 12),
609 preview: truncate_clean(&text, 180),
610 input_tokens: 0,
611 output_tokens: 0,
612 cache_tokens: 0,
613 total_tokens: 0,
614 tag: String::new(),
615 });
616 }
617 }
618 (AGENT_CODEX, "message" | "input" | "user") => {
619 if let Some(text) = local_message_preview(&obj) {
620 acc.prompt_preview = Some(text.clone());
621 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
622 }
623 }
624 _ if acc.prompt_preview.is_none() && typ.contains("user") => {
625 if let Some(text) = local_message_preview(&obj) {
626 acc.prompt_preview = Some(text.clone());
627 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
628 }
629 }
630 _ => {}
631 }
632 }
633
634 if acc.model_usage.is_empty() {
635 acc.model_usage = claude_message_models;
636 }
637 acc.finish_with_events(events)
638}
639
640fn parse_gemini_json(path: &Path, updated: SystemTime, content: &str) -> Option<AgentSession> {
641 let root: Value = serde_json::from_str(content).ok()?;
642 let mut acc = SessionAccumulator::new(AGENT_GEMINI, path, updated);
643 let mut events = SessionEvents::default();
644 let mut current_prompt_index = 0usize;
645 if let Some(id) = root.get("sessionId").and_then(Value::as_str) {
646 acc.session_id = id.to_string();
647 acc.conversation_id = Some(id.to_string());
648 }
649 acc.start_timestamp_ms = root
650 .get("startTime")
651 .and_then(Value::as_str)
652 .and_then(iso_ms);
653 acc.end_timestamp_ms = root
654 .get("lastUpdated")
655 .and_then(Value::as_str)
656 .and_then(iso_ms)
657 .or(acc.start_timestamp_ms);
658 acc.duration_ms = acc
659 .start_timestamp_ms
660 .zip(acc.end_timestamp_ms)
661 .map(|(start, end)| end.saturating_sub(start))
662 .unwrap_or_default();
663
664 let Some(messages) = root.get("messages").and_then(Value::as_array) else {
665 return acc.finish_with_events(events);
666 };
667 for msg in messages {
668 if let Some(ts) = msg.get("timestamp").and_then(Value::as_str) {
669 acc.last_message_at = Some(ts.to_string());
670 }
671 let ts_ms = msg
672 .get("timestamp")
673 .and_then(Value::as_str)
674 .and_then(parse_ts_ms);
675 match msg.get("type").and_then(Value::as_str) {
676 Some("user") if acc.prompt_preview.is_none() => {
677 if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
678 acc.prompt_preview = Some(text.clone());
679 current_prompt_index = events.upsert_prompt(ts_ms, &text);
680 }
681 }
682 Some("user") => {
683 if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
684 current_prompt_index = events.upsert_prompt(ts_ms, &text);
685 }
686 }
687 Some("gemini") | Some("assistant") | Some("model") => {
688 let mut llm_model = AGENT_GEMINI.to_string();
689 if let Some(model) = msg.get("model").and_then(Value::as_str) {
690 llm_model = model.to_string();
691 acc.model.get_or_insert_with(|| model.to_string());
692 if let Some(tokens) = msg.get("tokens") {
693 acc.add_usage(
694 model,
695 json_i64(tokens, "input"),
696 json_i64(tokens, "output"),
697 0,
698 json_i64(tokens, "cached"),
699 json_i64(tokens, "total"),
700 );
701 }
702 }
703 if let Some(tool_calls) = msg.get("toolCalls").and_then(Value::as_array) {
704 for call in tool_calls {
705 let name = call.get("name").and_then(Value::as_str).unwrap_or("?");
706 acc.add_tool(name);
707 if let Some(path) = find_file_arg(call).filter(|path| !is_noise_path(path))
708 {
709 acc.add_file(path);
710 }
711 events.tools.push(tool_event_from_input(
712 acc.cwd.as_deref(),
713 ts_ms,
714 current_prompt_index,
715 name,
716 call,
717 call.get("id").and_then(Value::as_str).map(str::to_string),
718 ));
719 }
720 }
721 let content = msg.get("content").unwrap_or(msg);
722 let text = content_to_text(content);
723 let tokens = msg.get("tokens").unwrap_or(&Value::Null);
724 if !text.trim().is_empty() || tokens.is_object() {
725 events.llm_responses.push(LlmResponse {
726 ts_ms,
727 prompt_index: current_prompt_index,
728 model: llm_model,
729 text_hash: short_hash(&(text.clone() + &tokens.to_string()), 12),
730 preview: truncate_clean(
731 if text.trim().is_empty() {
732 "gemini response"
733 } else {
734 &text
735 },
736 140,
737 ),
738 input_tokens: json_u64(tokens, "input"),
739 output_tokens: json_u64(tokens, "output"),
740 cache_tokens: json_u64(tokens, "cached"),
741 total_tokens: json_u64(tokens, "total"),
742 tag: String::new(),
743 });
744 }
745 }
746 _ => {}
747 }
748 }
749 acc.finish_with_events(events)
750}
751
752struct SessionAccumulator {
753 agent_type: String,
754 session_id: String,
755 conversation_id: Option<String>,
756 path: PathBuf,
757 updated: SystemTime,
758 start_timestamp_ms: Option<u64>,
759 end_timestamp_ms: Option<u64>,
760 model: Option<String>,
761 model_usage: BTreeMap<String, TokenUsage>,
762 tools: BTreeMap<String, usize>,
763 files: BTreeMap<String, usize>,
764 prompt_preview: Option<String>,
765 duration_ms: u64,
766 cwd: Option<String>,
767 last_message_at: Option<String>,
768}
769
770impl SessionAccumulator {
771 fn new(agent: &str, path: &Path, updated: SystemTime) -> Self {
772 let normalized = normalize_session_log_path(path);
773 let session_id = path
774 .file_stem()
775 .and_then(|stem| stem.to_str())
776 .unwrap_or("session")
777 .to_string();
778 Self {
779 agent_type: agent.to_string(),
780 session_id,
781 conversation_id: None,
782 path: normalized.clone(),
783 updated,
784 start_timestamp_ms: None,
785 end_timestamp_ms: Some(system_time_ms(updated)),
786 model: None,
787 model_usage: BTreeMap::new(),
788 tools: BTreeMap::new(),
789 files: BTreeMap::new(),
790 prompt_preview: None,
791 duration_ms: 0,
792 cwd: None,
793 last_message_at: None,
794 }
795 }
796
797 fn add_usage(
798 &mut self,
799 model: &str,
800 input: i64,
801 output: i64,
802 cache_creation: i64,
803 cache_read: i64,
804 total: i64,
805 ) {
806 add_usage(
807 &mut self.model_usage,
808 model,
809 input,
810 output,
811 cache_creation,
812 cache_read,
813 total,
814 );
815 }
816
817 fn set_usage(
818 &mut self,
819 model: &str,
820 input: i64,
821 output: i64,
822 cache_creation: i64,
823 cache_read: i64,
824 total: i64,
825 ) {
826 let mut usage = TokenUsage::default();
827 usage.add(input, output, cache_creation, cache_read, total);
828 self.model_usage.insert(model.to_string(), usage);
829 }
830
831 fn add_tool(&mut self, name: &str) {
832 *self.tools.entry(name.to_string()).or_default() += 1;
833 }
834
835 fn add_file(&mut self, path: &str) {
836 *self.files.entry(path.to_string()).or_default() += 1;
837 }
838
839 fn finish(self) -> Option<AgentSession> {
840 let token_usage =
841 self.model_usage
842 .values()
843 .fold(TokenUsage::default(), |mut total, usage| {
844 total.input_tokens += usage.input_tokens;
845 total.output_tokens += usage.output_tokens;
846 total.cache_creation_tokens += usage.cache_creation_tokens;
847 total.cache_read_tokens += usage.cache_read_tokens;
848 total.total_tokens += usage.total_tokens;
849 total
850 });
851 if token_usage.total_tokens == 0
852 && self.tools.is_empty()
853 && self.prompt_preview.is_none()
854 && self.model.is_none()
855 {
856 return None;
857 }
858 let display_id = format!("{}:{}", self.agent_type, short_session_id(&self.session_id));
859 Some(AgentSession {
860 agent_type: self.agent_type,
861 session_id: self.session_id,
862 conversation_id: self.conversation_id,
863 display_id,
864 path: self.path,
865 updated: self.updated,
866 start_timestamp_ms: self
867 .start_timestamp_ms
868 .or_else(|| Some(system_time_ms(self.updated).saturating_sub(self.duration_ms))),
869 end_timestamp_ms: self.end_timestamp_ms,
870 model: self.model,
871 usage: token_usage,
872 model_usage: self.model_usage,
873 tools: self.tools,
874 files: self.files,
875 prompt_preview: self.prompt_preview,
876 duration_ms: self.duration_ms,
877 cwd: self.cwd,
878 last_message_at: self.last_message_at,
879 events: SessionEvents::default(),
880 })
881 }
882
883 fn finish_with_events(self, events: SessionEvents) -> Option<AgentSession> {
884 self.finish().map(|mut session| {
885 session.events = events;
886 session
887 })
888 }
889}
890
891fn walk_agent_files(agent: &'static str, dir: &Path, f: &mut dyn FnMut(&Path, &fs::Metadata)) {
896 let Ok(entries) = fs::read_dir(dir) else {
897 return;
898 };
899 for entry in entries.flatten() {
900 let path = entry.path();
901 if path.is_dir() {
902 walk_agent_files(agent, &path, f);
903 } else if is_agent_file_for(agent, &path)
904 && let Ok(meta) = path.metadata()
905 {
906 f(&path, &meta);
907 }
908 }
909}
910
911fn is_agent_session_file(path: &Path) -> bool {
912 agent_source_for_path(path).is_some()
913}
914
915fn is_agent_file_for(agent: &str, path: &Path) -> bool {
916 match agent {
917 AGENT_CLAUDE | AGENT_CODEX => {
918 path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
919 }
920 AGENT_GEMINI => {
921 path.extension().and_then(|ext| ext.to_str()) == Some("json")
922 && path
923 .file_name()
924 .and_then(|name| name.to_str())
925 .is_some_and(|name| name.starts_with("session-"))
926 && path.to_string_lossy().contains("/chats/")
927 }
928 _ => false,
929 }
930}
931
932pub(crate) fn user_home_dir() -> Option<PathBuf> {
933 std::env::var("SUDO_USER")
934 .ok()
935 .and_then(|user| {
936 fs::read_to_string("/etc/passwd").ok().and_then(|passwd| {
937 passwd
938 .lines()
939 .find(|line| line.starts_with(&format!("{user}:")))
940 .and_then(|line| line.split(':').nth(5))
941 .map(PathBuf::from)
942 })
943 })
944 .or_else(dirs::home_dir)
945}
946
947fn add_usage(
948 models: &mut BTreeMap<String, TokenUsage>,
949 model: &str,
950 input: i64,
951 output: i64,
952 cache_creation: i64,
953 cache_read: i64,
954 total: i64,
955) {
956 models.entry(model.to_string()).or_default().add(
957 input,
958 output,
959 cache_creation,
960 cache_read,
961 total,
962 );
963}
964
965impl SessionEvents {
966 fn upsert_prompt(&mut self, ts_ms: Option<i64>, text: &str) -> usize {
967 let hash = short_hash(text, 12);
968 if let Some(existing) = self
969 .prompts
970 .iter()
971 .position(|prompt| prompt.text_hash == hash)
972 {
973 return existing;
974 }
975 let index = self.prompts.len();
976 self.prompts.push(UserPrompt {
977 index,
978 ts_ms,
979 text_hash: hash,
980 preview: truncate_clean(text, 180),
981 tag: String::new(),
982 });
983 index
984 }
985}
986
987fn tool_event_from_input(
988 cwd: Option<&str>,
989 ts_ms: Option<i64>,
990 prompt_index: usize,
991 name: &str,
992 input: &Value,
993 call_id: Option<String>,
994) -> ToolEvent {
995 let command = command_from_tool_input(input);
996 let category = tool_category(name, &command);
997 let domains = extract_domains(&command);
998 let command_name = if category == "shell" {
999 basename_from_command(&command)
1000 } else if category == "network" && !domains.is_empty() {
1001 domains[0]
1002 .split(':')
1003 .next()
1004 .unwrap_or("network")
1005 .to_string()
1006 } else {
1007 one_word(name, "tool")
1008 };
1009 let effect = if name == "apply_patch" || command.contains("*** ") {
1010 "write".to_string()
1011 } else {
1012 command_effect(&command)
1013 };
1014 let cwd = cwd.unwrap_or("");
1015 let path_groups = extract_path_groups(Path::new(cwd), name, input, &command);
1016 let process_chain = if category == "shell" {
1017 command_process_chain(&command)
1018 } else {
1019 Vec::new()
1020 };
1021 ToolEvent {
1022 ts_ms,
1023 prompt_index,
1024 tool_name: name.to_string(),
1025 category,
1026 command,
1027 command_name,
1028 effect,
1029 process_chain,
1030 status: "observed".to_string(),
1031 path_groups,
1032 domains,
1033 call_id,
1034 }
1035}
1036
1037fn command_from_tool_input(input: &Value) -> String {
1038 for key in ["cmd", "command", "pattern", "file_path", "path", "text"] {
1039 if let Some(value) = input.get(key).and_then(Value::as_str)
1040 && !value.is_empty()
1041 {
1042 return if key == "pattern" {
1043 format!("search {value}")
1044 } else {
1045 value.to_string()
1046 };
1047 }
1048 }
1049 if input.is_null() {
1050 String::new()
1051 } else {
1052 truncate_clean(&input.to_string(), 300)
1053 }
1054}
1055
1056fn parse_tool_args(value: &Value) -> Value {
1057 if let Some(text) = value.as_str() {
1058 serde_json::from_str(text).unwrap_or_else(|_| serde_json::json!({ "text": text }))
1059 } else {
1060 value.clone()
1061 }
1062}
1063
1064fn status_from_output(output: &str) -> &'static str {
1065 let lowered = output.to_ascii_lowercase();
1066 if lowered.contains("process exited with code 0") || lowered.contains("\"is_error\":false") {
1067 "ok"
1068 } else if lowered.contains("process exited with code")
1069 || lowered.contains("\"is_error\":true")
1070 || lowered.contains("error")
1071 {
1072 "fail"
1073 } else {
1074 "observed"
1075 }
1076}
1077
1078pub fn tool_category(name: &str, command: &str) -> String {
1079 let n = name.to_ascii_lowercase();
1080 if n.ends_with("exec_command") || n == "bash" {
1081 "shell"
1082 } else if ["apply_patch", "edit", "write", "multiedit", "notebookedit"].contains(&n.as_str()) {
1083 "edit"
1084 } else if ["read", "grep", "glob", "ls"].contains(&n.as_str()) {
1085 "read"
1086 } else if n.contains("web")
1087 || n.contains("browser")
1088 || n.contains("search")
1089 || command.contains("http")
1090 {
1091 "network"
1092 } else if n.contains("plan") || n.contains("todo") {
1093 "plan"
1094 } else if n.contains("task") || n.contains("agent") {
1095 "subagent"
1096 } else {
1097 "tool"
1098 }
1099 .to_string()
1100}
1101
1102fn command_effect(command: &str) -> String {
1103 let cmd = basename_from_command(command);
1104 let text = command.to_ascii_lowercase();
1105 if ["cargo", "pytest", "npm", "pnpm", "yarn", "go", "make"].contains(&cmd.as_str())
1106 && any_word(&text, &["test", "check", "build", "clippy"])
1107 {
1108 "test"
1109 } else if cmd == "git"
1110 && any_word(
1111 &text,
1112 &["commit", "push", "add", "checkout", "merge", "rebase"],
1113 )
1114 {
1115 "repo"
1116 } else if ["curl", "wget", "ssh", "scp", "git"].contains(&cmd.as_str())
1117 && (any_word(
1118 &text,
1119 &["clone", "fetch", "pull", "push", "curl", "wget", "ssh"],
1120 ) || text.contains("http://")
1121 || text.contains("https://"))
1122 {
1123 "network"
1124 } else if [
1125 "tee", "cp", "mv", "rm", "mkdir", "touch", "python", "python3", "node", "npm",
1126 ]
1127 .contains(&cmd.as_str())
1128 && (text.contains('>')
1129 || text.contains("--write")
1130 || text.contains(" rm ")
1131 || text.contains(" mkdir ")
1132 || text.contains(" touch ")
1133 || text.contains(" cp ")
1134 || text.contains(" mv "))
1135 {
1136 "write"
1137 } else if [
1138 "rg", "grep", "sed", "cat", "head", "tail", "find", "ls", "nl", "wc", "jq", "git",
1139 ]
1140 .contains(&cmd.as_str())
1141 {
1142 "read"
1143 } else if text.contains("http://")
1144 || text.contains("https://")
1145 || text.contains("crates.io")
1146 || text.contains("github.com")
1147 {
1148 "network"
1149 } else {
1150 "process"
1151 }
1152 .to_string()
1153}
1154
1155fn any_word(text: &str, words: &[&str]) -> bool {
1156 text.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
1157 .any(|part| words.contains(&part))
1158}
1159
1160fn basename_from_command(command: &str) -> String {
1161 let parts = split_shell(command);
1162 let mut idx = 0;
1163 while idx < parts.len()
1164 && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
1165 &Path::new(&parts[idx])
1166 .file_name()
1167 .and_then(|v| v.to_str())
1168 .unwrap_or(""),
1169 )
1170 {
1171 idx += 1;
1172 if idx < parts.len() && parts[idx].starts_with('-') {
1173 idx += 1;
1174 }
1175 }
1176 parts
1177 .get(idx)
1178 .and_then(|part| process_name_from_part(part))
1179 .unwrap_or_else(|| "none".to_string())
1180}
1181
1182pub fn command_process_chain(command: &str) -> Vec<String> {
1183 process_chain_from_parts(&split_shell(command))
1184}
1185
1186fn process_chain_from_parts(parts: &[String]) -> Vec<String> {
1187 if parts.is_empty() {
1188 return Vec::new();
1189 }
1190 let mut idx = 0;
1191 while idx < parts.len()
1192 && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
1193 &Path::new(&parts[idx])
1194 .file_name()
1195 .and_then(|v| v.to_str())
1196 .unwrap_or(""),
1197 )
1198 {
1199 idx += 1;
1200 if idx < parts.len() && parts[idx].starts_with('-') {
1201 idx += 1;
1202 }
1203 }
1204 let Some(proc_name) = parts.get(idx).and_then(|part| process_name_from_part(part)) else {
1205 return Vec::new();
1206 };
1207 let mut chain = vec![proc_name.clone()];
1208 if ["bash", "sh", "zsh"].contains(&proc_name.as_str()) {
1209 for flag_idx in idx + 1..parts.len().saturating_sub(1) {
1210 if ["-c", "-lc", "-cl"].contains(&parts[flag_idx].as_str()) {
1211 chain.extend(command_process_chain(&parts[flag_idx + 1]));
1212 break;
1213 }
1214 }
1215 }
1216 chain
1217}
1218
1219fn process_name_from_part(part: &str) -> Option<String> {
1220 let raw = part.trim_matches(['"', '\'']);
1221 if raw.is_empty() {
1222 return None;
1223 }
1224 let path = Path::new(raw);
1225 let file_name = path.file_name().and_then(|v| v.to_str()).unwrap_or(raw);
1226 let parts = path_component_strings(path);
1227 if looks_like_home_directory(&parts) && parts.len() <= 2 {
1228 return Some("external".to_string());
1229 }
1230 if contains_private_marker(file_name) {
1231 return Some("external".to_string());
1232 }
1233 Some(file_name.to_string())
1234}
1235
1236fn split_shell(command: &str) -> Vec<String> {
1237 let mut parts = Vec::new();
1238 let mut current = String::new();
1239 let mut quote = None;
1240 let mut escaped = false;
1241 for ch in command.chars() {
1242 if escaped {
1243 current.push(ch);
1244 escaped = false;
1245 } else if ch == '\\' {
1246 escaped = true;
1247 } else if quote == Some(ch) {
1248 quote = None;
1249 } else if quote.is_some() {
1250 current.push(ch);
1251 } else if ch == '\'' || ch == '"' {
1252 quote = Some(ch);
1253 } else if ch.is_whitespace() {
1254 if !current.is_empty() {
1255 parts.push(std::mem::take(&mut current));
1256 }
1257 } else {
1258 current.push(ch);
1259 }
1260 }
1261 if !current.is_empty() {
1262 parts.push(current);
1263 }
1264 parts
1265}
1266
1267fn extract_domains(text: &str) -> Vec<String> {
1268 let mut domains = BTreeSet::new();
1269 for part in text.split(|c: char| c.is_whitespace() || ['"', '\'', ')', '('].contains(&c)) {
1270 let stripped = part
1271 .strip_prefix("https://")
1272 .or_else(|| part.strip_prefix("http://"));
1273 if let Some(rest) = stripped
1274 && let Some(domain) = rest.split('/').next()
1275 && !domain.is_empty()
1276 {
1277 domains.insert(domain.to_ascii_lowercase());
1278 }
1279 for known in [
1280 "github.com",
1281 "crates.io",
1282 "huggingface.co",
1283 "hf.co",
1284 "openai.com",
1285 "anthropic.com",
1286 ] {
1287 if part.contains(known) {
1288 domains.insert(known.to_string());
1289 }
1290 }
1291 }
1292 domains.into_iter().collect()
1293}
1294
1295fn extract_path_groups(
1296 project_root: &Path,
1297 name: &str,
1298 input: &Value,
1299 command: &str,
1300) -> Vec<String> {
1301 let mut groups = BTreeSet::new();
1302 if ["write", "edit", "multiedit", "notebookedit", "read"]
1303 .contains(&name.to_ascii_lowercase().as_str())
1304 {
1305 for key in ["file_path", "path"] {
1306 if let Some(path) = input.get(key).and_then(Value::as_str) {
1307 groups.insert(path_group(path, project_root));
1308 }
1309 }
1310 }
1311 for part in split_shell(command) {
1312 if plausible_path_token(&part) {
1313 groups.insert(path_group(&part, project_root));
1314 }
1315 }
1316 groups.into_iter().filter(|v| v != "none").collect()
1317}
1318
1319fn plausible_path_token(part: &str) -> bool {
1320 let part = part.trim_matches(['"', '\'']);
1321 if part.is_empty()
1322 || part.starts_with('-')
1323 || part.starts_with('$')
1324 || part.starts_with("http://")
1325 || part.starts_with("https://")
1326 || part.len() > 140
1327 || part.chars().any(|c| "{}()=;<>|`".contains(c))
1328 {
1329 return false;
1330 }
1331 let suffix = Path::new(part)
1332 .extension()
1333 .and_then(|v| v.to_str())
1334 .unwrap_or("");
1335 part.contains('/')
1336 || [
1337 "rs", "py", "md", "json", "ts", "tsx", "toml", "lock", "js", "c", "h", "svg", "html",
1338 "css",
1339 ]
1340 .contains(&suffix)
1341}
1342
1343pub fn path_group(path: &str, project_root: &Path) -> String {
1344 let path = path.trim_matches(['"', '\'']);
1345 if path.is_empty() {
1346 return "none".to_string();
1347 }
1348 let p = Path::new(path);
1349 let parts = if p.is_absolute() {
1350 if let Ok(rel) = p.strip_prefix(project_root) {
1351 path_component_strings(rel)
1352 } else {
1353 return external_path_group(path, &path_component_strings(p));
1354 }
1355 } else {
1356 let parts = path_component_strings(p);
1357 if let Some(group) = sensitive_relative_path_group(path, &parts) {
1358 return group;
1359 }
1360 parts
1361 };
1362 collapse_project_path(parts)
1363}
1364
1365pub fn path_component_strings(path: &Path) -> Vec<String> {
1366 path.components()
1367 .filter_map(|c| {
1368 let part = c.as_os_str().to_string_lossy();
1369 let part = part.as_ref();
1370 if part == "." || part == "/" || part.is_empty() {
1371 None
1372 } else {
1373 Some(part.to_string())
1374 }
1375 })
1376 .collect()
1377}
1378
1379pub fn collapse_project_path(parts: Vec<String>) -> String {
1380 let parts = parts
1381 .into_iter()
1382 .filter(|part| part != "." && !part.is_empty())
1383 .map(|part| truncate_path_component(&part))
1384 .collect::<Vec<_>>();
1385 if parts.is_empty() {
1386 "repo".to_string()
1387 } else if [
1388 "collector",
1389 "frontend",
1390 "docs",
1391 "bpf",
1392 "agentpprof",
1393 "agent-session",
1394 ]
1395 .contains(&parts[0].as_str())
1396 {
1397 parts.into_iter().take(3).collect::<Vec<_>>().join("/")
1398 } else {
1399 parts.into_iter().take(2).collect::<Vec<_>>().join("/")
1400 }
1401}
1402
1403fn truncate_path_component(part: &str) -> String {
1404 if part.chars().count() > 48 {
1405 format!("{}...", part.chars().take(45).collect::<String>())
1406 } else {
1407 part.to_string()
1408 }
1409}
1410
1411fn external_path_group(raw: &str, parts: &[String]) -> String {
1412 sensitive_relative_path_group(raw, parts).unwrap_or_else(|| "external/path".to_string())
1413}
1414
1415fn sensitive_relative_path_group(raw: &str, parts: &[String]) -> Option<String> {
1416 let lowered = raw.to_ascii_lowercase();
1417 let lower_parts = parts
1418 .iter()
1419 .map(|part| part.to_ascii_lowercase())
1420 .collect::<Vec<_>>();
1421 if lower_parts.iter().any(|part| part == ".codex") {
1422 Some("external/codex".to_string())
1423 } else if lower_parts.iter().any(|part| part == ".claude") {
1424 Some("external/claude".to_string())
1425 } else if lower_parts.first().is_some_and(|part| part == "tmp")
1426 || lowered.contains("/tmp")
1427 || lowered.contains("_/tmp")
1428 || lower_parts
1429 .windows(2)
1430 .any(|window| window[0] == "var" && window[1] == "tmp")
1431 {
1432 Some("external/tmp".to_string())
1433 } else if lowered.starts_with("~/")
1434 || lowered == "~"
1435 || lowered.contains("/home")
1436 || lowered.contains("_/home")
1437 || lowered.contains("-home-")
1438 || lowered.contains("/users")
1439 || lowered.contains("_/users")
1440 || looks_like_home_directory(&lower_parts)
1441 || contains_private_marker(&lowered)
1442 {
1443 Some("external/home".to_string())
1444 } else {
1445 None
1446 }
1447}
1448
1449pub fn looks_like_home_directory(parts: &[String]) -> bool {
1450 parts
1451 .first()
1452 .is_some_and(|part| part == "home" || part == "users")
1453}
1454
1455fn current_username() -> Option<String> {
1456 dirs::home_dir()
1457 .and_then(|home| {
1458 home.file_name()
1459 .map(|part| part.to_string_lossy().to_string())
1460 })
1461 .filter(|name| !name.is_empty())
1462}
1463
1464pub fn contains_private_marker(text: &str) -> bool {
1465 let lowered = text.to_ascii_lowercase();
1466 current_username()
1467 .map(|name| lowered.contains(&name.to_ascii_lowercase()))
1468 .unwrap_or(false)
1469}
1470
1471fn content_to_text(value: &Value) -> String {
1472 match value {
1473 Value::String(s) => s.clone(),
1474 Value::Array(items) => items
1475 .iter()
1476 .filter_map(|item| {
1477 if let Some(text) = item.as_str() {
1478 return Some(text.to_string());
1479 }
1480 let typ = item.get("type").and_then(Value::as_str).unwrap_or("");
1481 if typ == "tool_result" || typ == "tool_use" || typ == "function_call" {
1482 return None;
1483 }
1484 if typ == "thinking" {
1486 return item
1487 .get("thinking")
1488 .and_then(Value::as_str)
1489 .filter(|s| !s.is_empty())
1490 .map(str::to_string);
1491 }
1492 item.get("text")
1493 .or_else(|| item.get("content"))
1494 .and_then(Value::as_str)
1495 .map(str::to_string)
1496 })
1497 .collect::<Vec<_>>()
1498 .join("\n"),
1499 Value::Object(_) => value
1500 .get("text")
1501 .or_else(|| value.get("content"))
1502 .and_then(Value::as_str)
1503 .unwrap_or("")
1504 .to_string(),
1505 _ => String::new(),
1506 }
1507}
1508
1509fn claude_is_tool_result(content: &Value) -> bool {
1510 content.as_array().is_some_and(|items| {
1511 !items.is_empty()
1512 && items
1513 .iter()
1514 .all(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
1515 })
1516}
1517
1518fn claude_tool_result_ids(content: &Value) -> Vec<String> {
1519 content
1520 .as_array()
1521 .into_iter()
1522 .flatten()
1523 .filter_map(|item| {
1524 item.get("tool_use_id")
1525 .and_then(Value::as_str)
1526 .map(str::to_string)
1527 })
1528 .collect()
1529}
1530
1531fn local_session_ids(obj: &Value) -> (Option<String>, Option<String>) {
1532 let session_id = first_json_string(
1533 obj,
1534 &["sessionId", "session_id"],
1535 &["/payload/session_id", "/payload/sessionId"],
1536 );
1537 let conversation_id = first_json_string(
1538 obj,
1539 &["conversation_id", "conversationId", "thread_id", "threadId"],
1540 &[
1541 "/payload/conversation_id",
1542 "/payload/conversationId",
1543 "/payload/thread_id",
1544 "/payload/threadId",
1545 ],
1546 )
1547 .or_else(|| session_id.clone());
1548 (
1549 session_id.or_else(|| conversation_id.clone()),
1550 conversation_id,
1551 )
1552}
1553
1554fn first_json_string(obj: &Value, keys: &[&str], pointers: &[&str]) -> Option<String> {
1555 keys.iter()
1556 .filter_map(|key| obj.get(*key).and_then(Value::as_str))
1557 .chain(
1558 pointers
1559 .iter()
1560 .filter_map(|pointer| obj.pointer(pointer).and_then(Value::as_str)),
1561 )
1562 .find(|value| !value.is_empty())
1563 .map(str::to_string)
1564}
1565
1566fn codex_exec_option_arity(arg: &str) -> Option<usize> {
1567 if arg.contains('=') && arg.starts_with("--") {
1568 return Some(1);
1569 }
1570
1571 match arg {
1572 "--json"
1573 | "--skip-git-repo-check"
1574 | "--ephemeral"
1575 | "--ignore-user-config"
1576 | "--full-auto"
1577 | "--dangerously-bypass-approvals-and-sandbox" => Some(1),
1578 "-C" | "-a" | "-s" | "-m" | "-c" | "-p" | "--cd" | "--model" | "--sandbox"
1579 | "--profile" | "--config" | "--ask-for-approval" | "--approval-policy"
1580 | "--output-format" | "--color" => Some(2),
1581 _ => None,
1582 }
1583}
1584
1585fn shell_words(input: &str) -> Option<Vec<String>> {
1586 let mut words = Vec::new();
1587 let mut current = String::new();
1588 let mut quote = None::<char>;
1589 let mut chars = input.chars().peekable();
1590
1591 while let Some(ch) = chars.next() {
1592 match (quote, ch) {
1593 (None, c) if c.is_whitespace() => {
1594 if !current.is_empty() {
1595 words.push(std::mem::take(&mut current));
1596 }
1597 }
1598 (None, '\'' | '"') => quote = Some(ch),
1599 (Some(q), c) if c == q => quote = None,
1600 (_, '\\') => {
1601 if let Some(next) = chars.next() {
1602 current.push(next);
1603 }
1604 }
1605 _ => current.push(ch),
1606 }
1607 }
1608 if quote.is_some() {
1609 return None;
1610 }
1611 if !current.is_empty() {
1612 words.push(current);
1613 }
1614 Some(words)
1615}
1616
1617fn claude_usage_key(obj: &Value) -> String {
1618 obj.get("requestId")
1619 .or_else(|| obj.pointer("/message/id"))
1620 .or_else(|| obj.get("uuid"))
1621 .and_then(Value::as_str)
1622 .unwrap_or("usage")
1623 .to_string()
1624}
1625
1626fn local_message_preview(value: &Value) -> Option<String> {
1627 let mut parts = Vec::new();
1628 collect_local_text(value, &mut parts);
1629 clean_prompt_text(&parts.join(" "))
1630}
1631
1632fn collect_local_text(value: &Value, out: &mut Vec<String>) {
1633 match value {
1634 Value::String(text) => out.push(text.clone()),
1635 Value::Array(items) => {
1636 for item in items {
1637 collect_local_text(item, out);
1638 }
1639 }
1640 Value::Object(obj) => {
1641 if obj.get("type").and_then(Value::as_str).is_some_and(|typ| {
1642 typ == "tool_use" || typ == "function_call" || typ == "tool_result"
1643 }) {
1644 return;
1645 }
1646 for key in ["text", "content", "message", "input", "prompt"] {
1647 if let Some(value) = obj.get(key) {
1648 collect_local_text(value, out);
1649 }
1650 }
1651 }
1652 _ => {}
1653 }
1654}
1655
1656fn is_claude_tool_result(obj: &Value) -> bool {
1657 obj.get("toolUseResult").is_some()
1658 || obj.get("tool_use_result").is_some()
1659 || obj
1660 .pointer("/message/content")
1661 .and_then(Value::as_array)
1662 .is_some_and(|items| {
1663 items
1664 .iter()
1665 .any(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
1666 })
1667}
1668
1669fn find_file_arg(value: &Value) -> Option<&str> {
1670 match value {
1671 Value::Object(obj) => {
1672 for key in ["file_path", "path", "filepath"] {
1673 if let Some(path) = obj.get(key).and_then(Value::as_str) {
1674 return Some(path);
1675 }
1676 }
1677 obj.values().find_map(find_file_arg)
1678 }
1679 Value::Array(items) => items.iter().find_map(find_file_arg),
1680 _ => None,
1681 }
1682}
1683
1684fn is_noise_path(path: &str) -> bool {
1685 const NOISE: &[&str] = &[
1686 "/.claude/",
1687 "/.codex/",
1688 "/.gemini/",
1689 "/.git/",
1690 "/node_modules/",
1691 "/.npm/",
1692 "/.cache/",
1693 "CLAUDE.md",
1694 "AGENTS.md",
1695 ];
1696 NOISE.iter().any(|pat| path.contains(pat))
1697}
1698
1699fn clean_prompt_text(text: &str) -> Option<String> {
1700 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
1701 let text = text
1702 .strip_prefix("<session>")
1703 .and_then(|text| text.strip_suffix("</session>"))
1704 .unwrap_or(&text)
1705 .trim();
1706 (!text.is_empty()).then(|| text.to_string())
1707}
1708
1709pub fn short_hash(text: &str, n: usize) -> String {
1710 let digest = Sha256::digest(text.as_bytes());
1711 hex::encode(digest).chars().take(n).collect()
1712}
1713
1714pub fn truncate_clean(text: &str, limit: usize) -> String {
1715 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
1716 if text.chars().count() <= limit {
1717 return text;
1718 }
1719 text.chars()
1720 .take(limit.saturating_sub(1))
1721 .collect::<String>()
1722 + "."
1723}
1724
1725pub fn one_word(text: &str, default: &str) -> String {
1726 let mut cur = String::new();
1727 for ch in text.to_ascii_lowercase().chars() {
1728 if ch.is_ascii_alphanumeric() {
1729 cur.push(ch);
1730 } else if cur.len() >= 2 {
1731 break;
1732 } else {
1733 cur.clear();
1734 }
1735 }
1736 if cur.len() >= 2 {
1737 cur
1738 } else {
1739 default.to_string()
1740 }
1741}
1742
1743fn short_session_id(id: &str) -> String {
1744 let id = id.trim();
1745 if id.is_empty() {
1746 return "session".to_string();
1747 }
1748 let compact = id
1749 .rsplit(['/', '\\'])
1750 .next()
1751 .unwrap_or(id)
1752 .trim_end_matches(".jsonl");
1753 const MAX_SESSION_ID_CHARS: usize = 12;
1754 if compact.chars().count() <= MAX_SESSION_ID_CHARS {
1755 return compact.to_string();
1756 }
1757 let head = compact.chars().take(6).collect::<String>();
1758 let tail = compact
1759 .chars()
1760 .rev()
1761 .take(5)
1762 .collect::<Vec<_>>()
1763 .into_iter()
1764 .rev()
1765 .collect::<String>();
1766 format!("{head}.{tail}")
1767}
1768
1769fn json_i64(value: &Value, key: &str) -> i64 {
1770 value.get(key).and_then(Value::as_i64).unwrap_or(0)
1771}
1772
1773fn json_u64(value: &Value, key: &str) -> u64 {
1774 value.get(key).and_then(Value::as_u64).unwrap_or(0)
1775}
1776
1777fn codex_uncached_input(input: i64, cache: i64) -> i64 {
1778 input.saturating_sub(cache)
1779}
1780
1781fn ts_ms_from_event(value: &Value) -> Option<i64> {
1782 value
1783 .get("timestamp")
1784 .and_then(Value::as_str)
1785 .and_then(parse_ts_ms)
1786}
1787
1788fn parse_ts_ms(value: &str) -> Option<i64> {
1789 chrono::DateTime::parse_from_rfc3339(value)
1790 .ok()
1791 .map(|ts| ts.timestamp_millis())
1792}
1793
1794fn iso_ms(value: &str) -> Option<u64> {
1795 chrono::DateTime::parse_from_rfc3339(value)
1796 .ok()
1797 .and_then(|ts| u64::try_from(ts.timestamp_millis()).ok())
1798}
1799
1800fn system_time_ms(value: SystemTime) -> u64 {
1801 value
1802 .duration_since(UNIX_EPOCH)
1803 .unwrap_or_default()
1804 .as_millis() as u64
1805}
1806
1807#[cfg(test)]
1808mod tests {
1809 use super::*;
1810 use serde_json::json;
1811 use std::time::UNIX_EPOCH;
1812
1813 #[test]
1814 fn local_session_ids_keep_distinct_conversation_id() {
1815 assert_eq!(
1816 local_session_ids(&json!({"sessionId": "run", "conversation_id": "conv"})),
1817 (Some("run".to_string()), Some("conv".to_string()))
1818 );
1819 assert_eq!(
1820 local_session_ids(&json!({"payload": {"thread_id": "thread"}})),
1821 (Some("thread".to_string()), Some("thread".to_string()))
1822 );
1823 assert_eq!(
1824 local_session_ids(&json!({"payload": {"model": "gpt"}})),
1825 (None, None)
1826 );
1827 }
1828
1829 #[test]
1830 fn agent_jsonl_events_share_one_ir() {
1831 let codex = concat!(
1832 r#"{"type":"turn_context","payload":{"model":"gpt-5","cwd":"/repo"}}"#,
1833 "\n",
1834 r#"{"type":"event_msg","payload":{"type":"user_message","message":"run tests"}}"#,
1835 "\n",
1836 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
1837 "\n",
1838 r#"{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}}"#,
1839 );
1840 let claude = concat!(
1841 r#"{"type":"user","message":{"content":"check build"}}"#,
1842 "\n",
1843 r#"{"type":"assistant","message":{"model":"claude-opus","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"cmd":"cargo check"}},{"type":"text","text":"checking"}],"usage":{"input_tokens":7,"cache_creation_input_tokens":2,"output_tokens":3}}}"#,
1844 );
1845
1846 for (agent, content, tool, model, tokens) in [
1847 (AGENT_CODEX, codex, "exec_command", "gpt-5", 15),
1848 (AGENT_CLAUDE, claude, "Bash", "claude-opus", 12),
1849 ] {
1850 let session = parse_session_content(
1851 agent,
1852 &PathBuf::from("/tmp/session.jsonl"),
1853 UNIX_EPOCH,
1854 content,
1855 )
1856 .expect("session");
1857 assert_eq!(session.events.tools[0].tool_name, tool);
1858 assert_eq!(session.events.tools[0].category, "shell");
1859 assert_eq!(session.events.llm_responses[0].model, model);
1860 let usage = &session.events.llm_responses[0];
1861 let total = usage
1862 .total_tokens
1863 .max(usage.input_tokens + usage.output_tokens + usage.cache_tokens);
1864 assert_eq!(total, tokens);
1865 }
1866 }
1867
1868 #[test]
1869 fn codex_token_count_uses_uncached_cumulative_totals() {
1870 let content = concat!(
1871 r#"{"type":"turn_context","payload":{"model":"gpt-5.6-sol","cwd":"/repo"}}"#,
1872 "\n",
1873 r#"{"type":"event_msg","payload":{"type":"user_message","message":"run tests"}}"#,
1874 "\n",
1875 r#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":2109758505,"output_tokens":5136738,"cached_input_tokens":2069213696,"total_tokens":2114895243}}}}"#,
1876 );
1877
1878 let session = parse_session_content(
1879 AGENT_CODEX,
1880 &PathBuf::from("/tmp/session.jsonl"),
1881 UNIX_EPOCH,
1882 content,
1883 )
1884 .expect("session");
1885
1886 assert_eq!(session.usage.input_tokens, 40_544_809);
1887 assert_eq!(session.usage.output_tokens, 5_136_738);
1888 assert_eq!(session.usage.cache_read_tokens, 2_069_213_696);
1889 assert_eq!(session.usage.total_tokens, 45_681_547);
1890
1891 let response = &session.events.llm_responses[0];
1892 assert_eq!(response.input_tokens, 40_544_809);
1893 assert_eq!(response.output_tokens, 5_136_738);
1894 assert_eq!(response.cache_tokens, 2_069_213_696);
1895 assert_eq!(response.total_tokens, 45_681_547);
1896 assert_eq!(codex_uncached_input(99_000_000, 88_000_000), 11_000_000);
1897 }
1898
1899 #[test]
1900 fn codex_exec_prompt_handles_latest_cli_options() {
1901 let command = concat!(
1902 "/tmp/tools/bin/codex exec --skip-git-repo-check --ignore-user-config ",
1903 "-c model_provider=\"agentsight-mock\" ",
1904 "-c model_providers.agentsight-mock.name=\"AgentSight Mock\" ",
1905 "--sandbox read-only --model gpt-agentsight-mock ",
1906 "agentsight mock prompt collect this exact text"
1907 );
1908
1909 assert_eq!(
1910 codex_exec_prompt(command).as_deref(),
1911 Some("agentsight mock prompt collect this exact text")
1912 );
1913 }
1914}