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