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