1use serde_json::Value;
7use sha2::{Digest, Sha256};
8use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
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, ToolPath, 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 is_claude_tool_result(&obj) {
420 let fallback = obj
421 .pointer("/toolUseResult/is_error")
422 .and_then(Value::as_bool)
423 .unwrap_or(false);
424 for result in content.as_array().into_iter().flatten() {
425 let Some(id) = result.get("tool_use_id").and_then(Value::as_str) else {
426 continue;
427 };
428 if let Some(index) = call_index.get(id).copied()
429 && let Some(tool) = events.tools.get_mut(index)
430 {
431 let failed = result
432 .get("is_error")
433 .and_then(Value::as_bool)
434 .unwrap_or(fallback);
435 tool.status = if failed { "fail" } else { "ok" }.to_string();
436 }
437 }
438 } else if let Some(text) = local_message_preview(content) {
439 if acc.prompt_preview.is_none() {
440 acc.prompt_preview = Some(text.clone());
441 }
442 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
443 }
444 }
445 (AGENT_CLAUDE, "last-prompt") => {
446 if let Some(text) = obj.get("lastPrompt").and_then(Value::as_str)
447 && let Some(text) = clean_prompt_text(text)
448 {
449 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
450 }
451 }
452 (AGENT_CODEX, "turn_context") => {
453 if let Some(name) = obj.pointer("/payload/model").and_then(Value::as_str) {
454 codex_model = name.to_string();
455 acc.model = Some(name.to_string());
456 }
457 }
458 (AGENT_CODEX, "event_msg") => {
459 let payload = obj.get("payload").unwrap_or(&Value::Null);
460 let ptype = payload.get("type").and_then(Value::as_str).unwrap_or("");
461 if ptype == "token_count"
462 && let Some(usage) = payload.pointer("/info/total_token_usage")
463 {
464 let name = if codex_model.is_empty() {
465 "unknown"
466 } else {
467 &codex_model
468 };
469 let usage = codex_token_usage(usage);
470 acc.set_usage(
471 name,
472 usage.input_tokens,
473 usage.output_tokens,
474 0,
475 usage.cache_read_tokens,
476 usage.total_tokens,
477 );
478 }
479 if matches!(ptype, "token_count" | "token_usage") {
480 let info = payload
481 .get("info")
482 .or_else(|| payload.get("usage"))
483 .unwrap_or(payload);
484 let token_usage = info
485 .get("last_token_usage")
486 .or_else(|| info.get("total_token_usage"))
487 .unwrap_or(info);
488 let input_tokens = json_u64(token_usage, "input_tokens");
489 let output_tokens = json_u64(token_usage, "output_tokens");
490 let cache_tokens = json_u64(token_usage, "cached_input_tokens");
491 let total_tokens = json_u64(token_usage, "total_tokens")
492 .max(json_u64(info, "total_tokens"))
493 .max(json_u64(info, "tokens"));
494 if total_tokens > 0 {
495 if let Some(last) = events.llm_responses.last_mut()
496 && last.total_tokens == 0
497 {
498 last.input_tokens = input_tokens;
499 last.output_tokens = output_tokens;
500 last.cache_tokens = cache_tokens;
501 last.total_tokens = total_tokens;
502 continue;
503 }
504 events.llm_responses.push(LlmResponse {
505 ts_ms: ts_ms_from_event(&obj),
506 prompt_index: current_prompt_index,
507 model: if codex_model.is_empty() {
508 AGENT_CODEX.to_string()
509 } else {
510 codex_model.clone()
511 },
512 text_hash: short_hash(&token_usage.to_string(), 12),
513 preview: "token report".to_string(),
514 input_tokens,
515 output_tokens,
516 cache_tokens,
517 total_tokens,
518 tag: String::new(),
519 });
520 }
521 }
522 if ptype == "user_message" {
523 let text = payload
524 .get("message")
525 .or_else(|| payload.get("content"))
526 .and_then(Value::as_str)
527 .unwrap_or("");
528 if let Some(text) = clean_prompt_text(text) {
529 acc.prompt_preview = Some(text.clone());
530 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
531 }
532 }
533 if ptype == "agent_message" {
534 let text = payload
535 .get("message")
536 .or_else(|| payload.get("content"))
537 .and_then(Value::as_str)
538 .unwrap_or("");
539 if let Some(text) = clean_prompt_text(text) {
540 events.llm_responses.push(LlmResponse {
541 ts_ms: ts_ms_from_event(&obj),
542 prompt_index: current_prompt_index,
543 model: if codex_model.is_empty() {
544 AGENT_CODEX.to_string()
545 } else {
546 codex_model.clone()
547 },
548 text_hash: short_hash(&text, 12),
549 preview: truncate_clean(&text, 180),
550 input_tokens: 0,
551 output_tokens: 0,
552 cache_tokens: 0,
553 total_tokens: 0,
554 tag: String::new(),
555 });
556 }
557 }
558 }
559 (AGENT_CODEX, "response_item")
560 if matches!(
561 obj.pointer("/payload/type").and_then(Value::as_str),
562 Some("function_call" | "custom_tool_call")
563 ) =>
564 {
565 let name = obj
566 .pointer("/payload/name")
567 .and_then(Value::as_str)
568 .unwrap_or("?");
569 acc.add_tool(name);
570 let payload = obj.get("payload").unwrap_or(&Value::Null);
571 let args = parse_tool_args(
572 payload
573 .get("arguments")
574 .or_else(|| payload.get("input"))
575 .unwrap_or(&Value::Null),
576 );
577 let call_id = payload
578 .get("call_id")
579 .and_then(Value::as_str)
580 .map(str::to_string);
581 let event = tool_event_from_input(
582 acc.cwd.as_deref(),
583 ts_ms_from_event(&obj),
584 current_prompt_index,
585 name,
586 &args,
587 call_id.clone(),
588 );
589 if let Some(id) = call_id {
590 call_index.insert(id, events.tools.len());
591 }
592 events.tools.push(event);
593 }
594 (AGENT_CODEX, "response_item")
595 if matches!(
596 obj.pointer("/payload/type").and_then(Value::as_str),
597 Some("function_call_output" | "custom_tool_call_output")
598 ) =>
599 {
600 if let Some(call_id) = obj.pointer("/payload/call_id").and_then(Value::as_str)
601 && let Some(index) = call_index.get(call_id).copied()
602 && let Some(tool) = events.tools.get_mut(index)
603 {
604 let output = obj
605 .pointer("/payload/output")
606 .and_then(Value::as_str)
607 .unwrap_or("");
608 tool.status = status_from_output(output).to_string();
609 }
610 }
611 (AGENT_CODEX, "response_item")
612 if obj.pointer("/payload/type").and_then(Value::as_str) == Some("message") =>
613 {
614 let payload = obj.get("payload").unwrap_or(&Value::Null);
615 let text = payload
616 .get("message")
617 .or_else(|| payload.get("content"))
618 .and_then(Value::as_str)
619 .unwrap_or("");
620 if let Some(text) = clean_prompt_text(text) {
621 events.llm_responses.push(LlmResponse {
622 ts_ms: ts_ms_from_event(&obj),
623 prompt_index: current_prompt_index,
624 model: if codex_model.is_empty() {
625 AGENT_CODEX.to_string()
626 } else {
627 codex_model.clone()
628 },
629 text_hash: short_hash(&text, 12),
630 preview: truncate_clean(&text, 180),
631 input_tokens: 0,
632 output_tokens: 0,
633 cache_tokens: 0,
634 total_tokens: 0,
635 tag: String::new(),
636 });
637 }
638 }
639 (AGENT_CODEX, "message" | "input" | "user") => {
640 if let Some(text) = local_message_preview(&obj) {
641 acc.prompt_preview = Some(text.clone());
642 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
643 }
644 }
645 _ if acc.prompt_preview.is_none() && typ.contains("user") => {
646 if let Some(text) = local_message_preview(&obj) {
647 acc.prompt_preview = Some(text.clone());
648 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
649 }
650 }
651 _ => {}
652 }
653 }
654
655 if acc.model_usage.is_empty() {
656 acc.model_usage = claude_message_models;
657 }
658 acc.finish_with_events(events)
659}
660
661fn codex_token_usage(value: &Value) -> TokenUsage {
662 let input = json_i64(value, "input_tokens").max(0);
663 let output = json_i64(value, "output_tokens").max(0);
664 let cache = json_i64(value, "cached_input_tokens").max(0);
665 let input = input.saturating_sub(cache);
666 TokenUsage {
667 input_tokens: input,
668 output_tokens: output,
669 cache_creation_tokens: 0,
670 cache_read_tokens: cache,
671 total_tokens: input + output + cache,
672 }
673}
674
675fn parse_gemini_json(path: &Path, updated: SystemTime, content: &str) -> Option<AgentSession> {
676 let root: Value = serde_json::from_str(content).ok()?;
677 let mut acc = SessionAccumulator::new(AGENT_GEMINI, path, updated);
678 let mut events = SessionEvents::default();
679 let mut current_prompt_index = 0usize;
680 if let Some(id) = root.get("sessionId").and_then(Value::as_str) {
681 acc.session_id = id.to_string();
682 acc.conversation_id = Some(id.to_string());
683 }
684 acc.start_timestamp_ms = root
685 .get("startTime")
686 .and_then(Value::as_str)
687 .and_then(iso_ms);
688 acc.end_timestamp_ms = root
689 .get("lastUpdated")
690 .and_then(Value::as_str)
691 .and_then(iso_ms)
692 .or(acc.start_timestamp_ms);
693 acc.duration_ms = acc
694 .start_timestamp_ms
695 .zip(acc.end_timestamp_ms)
696 .map(|(start, end)| end.saturating_sub(start))
697 .unwrap_or_default();
698
699 let Some(messages) = root.get("messages").and_then(Value::as_array) else {
700 return acc.finish_with_events(events);
701 };
702 for msg in messages {
703 if let Some(ts) = msg.get("timestamp").and_then(Value::as_str) {
704 acc.last_message_at = Some(ts.to_string());
705 }
706 let ts_ms = msg
707 .get("timestamp")
708 .and_then(Value::as_str)
709 .and_then(parse_ts_ms);
710 match msg.get("type").and_then(Value::as_str) {
711 Some("user") if acc.prompt_preview.is_none() => {
712 if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
713 acc.prompt_preview = Some(text.clone());
714 current_prompt_index = events.upsert_prompt(ts_ms, &text);
715 }
716 }
717 Some("user") => {
718 if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
719 current_prompt_index = events.upsert_prompt(ts_ms, &text);
720 }
721 }
722 Some("gemini") | Some("assistant") | Some("model") => {
723 let mut llm_model = AGENT_GEMINI.to_string();
724 if let Some(model) = msg.get("model").and_then(Value::as_str) {
725 llm_model = model.to_string();
726 acc.model.get_or_insert_with(|| model.to_string());
727 if let Some(tokens) = msg.get("tokens") {
728 acc.add_usage(
729 model,
730 json_i64(tokens, "input"),
731 json_i64(tokens, "output"),
732 0,
733 json_i64(tokens, "cached"),
734 json_i64(tokens, "total"),
735 );
736 }
737 }
738 if let Some(tool_calls) = msg.get("toolCalls").and_then(Value::as_array) {
739 for call in tool_calls {
740 let name = call.get("name").and_then(Value::as_str).unwrap_or("?");
741 acc.add_tool(name);
742 if let Some(path) = find_file_arg(call).filter(|path| !is_noise_path(path))
743 {
744 acc.add_file(path);
745 }
746 let mut event = tool_event_from_input(
747 acc.cwd.as_deref(),
748 ts_ms,
749 current_prompt_index,
750 name,
751 call,
752 call.get("id").and_then(Value::as_str).map(str::to_string),
753 );
754 event.status = match call.get("status").and_then(Value::as_str) {
755 Some("success") => "ok",
756 Some("error") => "fail",
757 _ => "observed",
758 }
759 .into();
760 events.tools.push(event);
761 }
762 }
763 let content = msg.get("content").unwrap_or(msg);
764 let text = content_to_text(content);
765 let tokens = msg.get("tokens").unwrap_or(&Value::Null);
766 if !text.trim().is_empty() || tokens.is_object() {
767 events.llm_responses.push(LlmResponse {
768 ts_ms,
769 prompt_index: current_prompt_index,
770 model: llm_model,
771 text_hash: short_hash(&(text.clone() + &tokens.to_string()), 12),
772 preview: truncate_clean(
773 if text.trim().is_empty() {
774 "gemini response"
775 } else {
776 &text
777 },
778 140,
779 ),
780 input_tokens: json_u64(tokens, "input"),
781 output_tokens: json_u64(tokens, "output"),
782 cache_tokens: json_u64(tokens, "cached"),
783 total_tokens: json_u64(tokens, "total"),
784 tag: String::new(),
785 });
786 }
787 }
788 _ => {}
789 }
790 }
791 acc.finish_with_events(events)
792}
793
794struct SessionAccumulator {
795 agent_type: String,
796 session_id: String,
797 conversation_id: Option<String>,
798 path: PathBuf,
799 updated: SystemTime,
800 start_timestamp_ms: Option<u64>,
801 end_timestamp_ms: Option<u64>,
802 model: Option<String>,
803 model_usage: BTreeMap<String, TokenUsage>,
804 tools: BTreeMap<String, usize>,
805 files: BTreeMap<String, usize>,
806 prompt_preview: Option<String>,
807 duration_ms: u64,
808 cwd: Option<String>,
809 last_message_at: Option<String>,
810}
811
812impl SessionAccumulator {
813 fn new(agent: &str, path: &Path, updated: SystemTime) -> Self {
814 let normalized = normalize_session_log_path(path);
815 let session_id = path
816 .file_stem()
817 .and_then(|stem| stem.to_str())
818 .unwrap_or("session")
819 .to_string();
820 Self {
821 agent_type: agent.to_string(),
822 session_id,
823 conversation_id: None,
824 path: normalized.clone(),
825 updated,
826 start_timestamp_ms: None,
827 end_timestamp_ms: Some(system_time_ms(updated)),
828 model: None,
829 model_usage: BTreeMap::new(),
830 tools: BTreeMap::new(),
831 files: BTreeMap::new(),
832 prompt_preview: None,
833 duration_ms: 0,
834 cwd: None,
835 last_message_at: None,
836 }
837 }
838
839 fn add_usage(
840 &mut self,
841 model: &str,
842 input: i64,
843 output: i64,
844 cache_creation: i64,
845 cache_read: i64,
846 total: i64,
847 ) {
848 add_usage(
849 &mut self.model_usage,
850 model,
851 input,
852 output,
853 cache_creation,
854 cache_read,
855 total,
856 );
857 }
858
859 fn set_usage(
860 &mut self,
861 model: &str,
862 input: i64,
863 output: i64,
864 cache_creation: i64,
865 cache_read: i64,
866 total: i64,
867 ) {
868 let mut usage = TokenUsage::default();
869 usage.add(input, output, cache_creation, cache_read, total);
870 self.model_usage.insert(model.to_string(), usage);
871 }
872
873 fn add_tool(&mut self, name: &str) {
874 *self.tools.entry(name.to_string()).or_default() += 1;
875 }
876
877 fn add_file(&mut self, path: &str) {
878 *self.files.entry(path.to_string()).or_default() += 1;
879 }
880
881 fn finish(self) -> Option<AgentSession> {
882 let token_usage =
883 self.model_usage
884 .values()
885 .fold(TokenUsage::default(), |mut total, usage| {
886 total.input_tokens += usage.input_tokens;
887 total.output_tokens += usage.output_tokens;
888 total.cache_creation_tokens += usage.cache_creation_tokens;
889 total.cache_read_tokens += usage.cache_read_tokens;
890 total.total_tokens += usage.total_tokens;
891 total
892 });
893 if token_usage.total_tokens == 0
894 && self.tools.is_empty()
895 && self.prompt_preview.is_none()
896 && self.model.is_none()
897 {
898 return None;
899 }
900 let display_id = format!("{}:{}", self.agent_type, short_session_id(&self.session_id));
901 Some(AgentSession {
902 agent_type: self.agent_type,
903 session_id: self.session_id,
904 conversation_id: self.conversation_id,
905 display_id,
906 path: self.path,
907 updated: self.updated,
908 start_timestamp_ms: self
909 .start_timestamp_ms
910 .or_else(|| Some(system_time_ms(self.updated).saturating_sub(self.duration_ms))),
911 end_timestamp_ms: self.end_timestamp_ms,
912 model: self.model,
913 usage: token_usage,
914 model_usage: self.model_usage,
915 tools: self.tools,
916 files: self.files,
917 prompt_preview: self.prompt_preview,
918 duration_ms: self.duration_ms,
919 cwd: self.cwd,
920 last_message_at: self.last_message_at,
921 events: SessionEvents::default(),
922 })
923 }
924
925 fn finish_with_events(self, events: SessionEvents) -> Option<AgentSession> {
926 self.finish().map(|mut session| {
927 session.events = events;
928 session
929 })
930 }
931}
932
933fn walk_agent_files(agent: &'static str, dir: &Path, f: &mut dyn FnMut(&Path, &fs::Metadata)) {
938 let Ok(entries) = fs::read_dir(dir) else {
939 return;
940 };
941 for entry in entries.flatten() {
942 let path = entry.path();
943 if path.is_dir() {
944 walk_agent_files(agent, &path, f);
945 } else if is_agent_file_for(agent, &path)
946 && let Ok(meta) = path.metadata()
947 {
948 f(&path, &meta);
949 }
950 }
951}
952
953fn is_agent_session_file(path: &Path) -> bool {
954 agent_source_for_path(path).is_some()
955}
956
957fn is_agent_file_for(agent: &str, path: &Path) -> bool {
958 match agent {
959 AGENT_CLAUDE | AGENT_CODEX => {
960 path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
961 }
962 AGENT_GEMINI => {
963 path.extension().and_then(|ext| ext.to_str()) == Some("json")
964 && path
965 .file_name()
966 .and_then(|name| name.to_str())
967 .is_some_and(|name| name.starts_with("session-"))
968 && path.to_string_lossy().contains("/chats/")
969 }
970 _ => false,
971 }
972}
973
974pub(crate) fn user_home_dir() -> Option<PathBuf> {
975 std::env::var("SUDO_USER")
976 .ok()
977 .and_then(|user| {
978 fs::read_to_string("/etc/passwd").ok().and_then(|passwd| {
979 passwd
980 .lines()
981 .find(|line| line.starts_with(&format!("{user}:")))
982 .and_then(|line| line.split(':').nth(5))
983 .map(PathBuf::from)
984 })
985 })
986 .or_else(dirs::home_dir)
987}
988
989fn add_usage(
990 models: &mut BTreeMap<String, TokenUsage>,
991 model: &str,
992 input: i64,
993 output: i64,
994 cache_creation: i64,
995 cache_read: i64,
996 total: i64,
997) {
998 models.entry(model.to_string()).or_default().add(
999 input,
1000 output,
1001 cache_creation,
1002 cache_read,
1003 total,
1004 );
1005}
1006
1007impl SessionEvents {
1008 fn upsert_prompt(&mut self, ts_ms: Option<i64>, text: &str) -> usize {
1009 let hash = short_hash(text, 12);
1010 if let Some(existing) = self
1011 .prompts
1012 .iter()
1013 .position(|prompt| prompt.text_hash == hash)
1014 {
1015 return existing;
1016 }
1017 let index = self.prompts.len();
1018 self.prompts.push(UserPrompt {
1019 index,
1020 ts_ms,
1021 text_hash: hash,
1022 preview: truncate_clean(text, 180),
1023 tag: String::new(),
1024 });
1025 index
1026 }
1027}
1028
1029fn tool_event_from_input(
1030 cwd: Option<&str>,
1031 ts_ms: Option<i64>,
1032 prompt_index: usize,
1033 name: &str,
1034 input: &Value,
1035 call_id: Option<String>,
1036) -> ToolEvent {
1037 let command = command_from_tool_input(input);
1038 let category = tool_category(name, &command);
1039 let domains = extract_domains(&command);
1040 let command_name = if category == "shell" {
1041 basename_from_command(&command)
1042 } else if category == "network" && !domains.is_empty() {
1043 domains[0]
1044 .split(':')
1045 .next()
1046 .unwrap_or("network")
1047 .to_string()
1048 } else {
1049 one_word(name, "tool")
1050 };
1051 let effect = if name == "apply_patch" || command.contains("*** ") {
1052 "write".to_string()
1053 } else {
1054 command_effect(&command)
1055 };
1056 let cwd = cwd.unwrap_or("");
1057 let path_groups = extract_path_groups(Path::new(cwd), name, input, &command);
1058 let paths = extract_tool_paths(name, input, &command, &effect);
1059 let process_chain = if category == "shell" {
1060 command_process_chain(&command)
1061 } else {
1062 Vec::new()
1063 };
1064 ToolEvent {
1065 ts_ms,
1066 prompt_index,
1067 tool_name: name.to_string(),
1068 category,
1069 command,
1070 command_name,
1071 effect,
1072 process_chain,
1073 status: "observed".to_string(),
1074 path_groups,
1075 paths,
1076 domains,
1077 call_id,
1078 }
1079}
1080
1081fn extract_tool_paths(name: &str, input: &Value, command: &str, effect: &str) -> Vec<ToolPath> {
1082 let lower = name.to_ascii_lowercase();
1083 let is_shell = lower.contains("bash") || lower.contains("exec") || lower.contains("shell");
1084 let default_access = if lower.contains("read")
1085 || lower.contains("grep")
1086 || lower.contains("glob")
1087 || lower.contains("search")
1088 {
1089 "read"
1090 } else if lower.contains("write")
1091 || lower.contains("edit")
1092 || lower.contains("replace")
1093 || lower.contains("patch")
1094 {
1095 "write"
1096 } else if is_shell {
1097 if effect == "read" { "read" } else { "write" }
1098 } else {
1099 return Vec::new();
1100 };
1101 let mut rows = BTreeMap::<String, (String, Option<String>)>::new();
1102 if !is_shell {
1103 collect_path_fields(input, default_access, &mut rows);
1104 }
1105
1106 let embedded_patch = embedded_json_string(command, "*** Begin Patch");
1107 let patch = input
1108 .get("patch")
1109 .or_else(|| input.get("input"))
1110 .or_else(|| input.get("text"))
1111 .and_then(Value::as_str)
1112 .filter(|value| value.contains("*** Begin Patch") && value.lines().count() > 1)
1113 .or(embedded_patch.as_deref())
1114 .or_else(|| {
1115 (command.contains("*** Begin Patch") && command.lines().count() > 1).then_some(command)
1116 });
1117 let mut has_patch = false;
1118 if let Some(patch) = patch {
1119 let mut pending_update = None;
1120 for line in patch.lines() {
1121 let marker = line.trim();
1122 for (prefix, access) in [
1123 ("*** Add File: ", "create"),
1124 ("*** Update File: ", "write"),
1125 ("*** Delete File: ", "delete"),
1126 ("*** Move to: ", "rename"),
1127 ] {
1128 if let Some(path) = marker.strip_prefix(prefix) {
1129 let path = clean_path_token(path);
1130 if !path.is_empty() {
1131 has_patch = true;
1132 if access == "write" {
1133 pending_update = Some(path.clone());
1134 } else if access == "rename"
1135 && let Some(source) = pending_update.take()
1136 {
1137 rows.remove(&source);
1138 rows.insert(path.clone(), ("rename".to_string(), Some(source)));
1139 continue;
1140 }
1141 rows.insert(path, (access.to_string(), None));
1142 }
1143 }
1144 }
1145 }
1146 }
1147
1148 if is_shell && !has_patch {
1149 for (path, access, previous_path) in shell_file_actions(command, input, 0) {
1150 rows.insert(path, (access, previous_path));
1151 }
1152 for nested in embedded_json_objects(command, "tools.exec_command(") {
1153 let nested_command = command_from_tool_input(&nested);
1154 for (path, access, previous_path) in shell_file_actions(&nested_command, &nested, 0) {
1155 rows.insert(path, (access, previous_path));
1156 }
1157 }
1158 }
1159 rows.into_iter()
1160 .map(|(path, (access, previous_path))| ToolPath {
1161 path,
1162 access,
1163 previous_path,
1164 })
1165 .collect()
1166}
1167
1168fn embedded_json_string(text: &str, needle: &str) -> Option<String> {
1169 let needle = text.find(needle)?;
1170 let start = text[..needle].rfind('"')?;
1171 let mut escaped = false;
1172 for (offset, ch) in text[start + 1..].char_indices() {
1173 if escaped {
1174 escaped = false;
1175 } else if ch == '\\' {
1176 escaped = true;
1177 } else if ch == '"' {
1178 return serde_json::from_str(&text[start..start + offset + 2]).ok();
1179 }
1180 }
1181 None
1182}
1183
1184fn embedded_json_objects(text: &str, marker: &str) -> Vec<Value> {
1185 let mut rows = Vec::new();
1186 let mut offset = 0;
1187 while let Some(found) = text[offset..].find(marker) {
1188 let start = offset + found + marker.len();
1189 let Some(open) = text[start..].find('{').map(|value| start + value) else {
1190 break;
1191 };
1192 let mut depth = 0;
1193 let mut quote = false;
1194 let mut escaped = false;
1195 let mut end = None;
1196 for (index, ch) in text[open..].char_indices() {
1197 if escaped {
1198 escaped = false;
1199 } else if ch == '\\' && quote {
1200 escaped = true;
1201 } else if ch == '"' {
1202 quote = !quote;
1203 } else if !quote && ch == '{' {
1204 depth += 1;
1205 } else if !quote && ch == '}' {
1206 depth -= 1;
1207 if depth == 0 {
1208 end = Some(open + index + 1);
1209 break;
1210 }
1211 }
1212 }
1213 let Some(end) = end else { break };
1214 if let Ok(value) = serde_json::from_str(&text[open..end]) {
1215 rows.push(value);
1216 }
1217 offset = end;
1218 }
1219 rows
1220}
1221
1222fn shell_file_actions(
1223 command: &str,
1224 input: &Value,
1225 depth: usize,
1226) -> Vec<(String, String, Option<String>)> {
1227 if depth > 2 {
1228 return Vec::new();
1229 }
1230 let mut cwd = ["workdir", "cwd"]
1231 .iter()
1232 .find_map(|key| input.get(*key).and_then(Value::as_str))
1233 .map(PathBuf::from);
1234 let mut rows = Vec::new();
1235 for parts in shell_segments(command) {
1236 let Some(command_index) = shell_command_index(&parts) else {
1237 continue;
1238 };
1239 let name = process_name_from_part(&parts[command_index]).unwrap_or_default();
1240 let operands = &parts[command_index + 1..];
1241 if name == "cd" {
1242 if let Some(path) = operands.iter().find(|value| !value.starts_with('-')) {
1243 let path = PathBuf::from(path);
1244 cwd = Some(if path.is_absolute() {
1245 path
1246 } else {
1247 cwd.take().unwrap_or_default().join(path)
1248 });
1249 }
1250 continue;
1251 }
1252 let mut actions = shell_segment_actions(&name, operands, input, depth);
1253 for (path, _, previous_path) in &mut actions {
1254 if !path.starts_with(['~', '$'])
1255 && !Path::new(path).is_absolute()
1256 && let Some(base) = &cwd
1257 {
1258 *path = base.join(&*path).to_string_lossy().into_owned();
1259 }
1260 *path = clean_path_token(path);
1261 if let Some(previous) = previous_path {
1262 if !previous.starts_with(['~', '$'])
1263 && !Path::new(previous).is_absolute()
1264 && let Some(base) = &cwd
1265 {
1266 *previous = base.join(&*previous).to_string_lossy().into_owned();
1267 }
1268 *previous = clean_path_token(previous);
1269 }
1270 }
1271 rows.extend(actions.into_iter().filter(|(path, _, _)| !path.is_empty()));
1272 }
1273 rows
1274}
1275
1276fn shell_segment_actions(
1277 name: &str,
1278 operands: &[String],
1279 input: &Value,
1280 depth: usize,
1281) -> Vec<(String, String, Option<String>)> {
1282 let mut rows = Vec::new();
1283 let mut values = Vec::new();
1284 let mut index = 0;
1285 while index < operands.len() {
1286 if is_redirection_token(&operands[index]) {
1287 if let Some(path) = operands.get(index + 1)
1288 && plausible_path_token(path)
1289 {
1290 let access = if [">", ">>", "&>", "&>>"].contains(&operands[index].as_str()) {
1291 "write"
1292 } else if ["<", "<>"].contains(&operands[index].as_str()) {
1293 "read"
1294 } else {
1295 index += 2;
1296 continue;
1297 };
1298 rows.push((path.clone(), access.into(), None));
1299 }
1300 index += 2;
1301 continue;
1302 }
1303 values.push(operands[index].clone());
1304 index += 1;
1305 }
1306 let paths = |items: &[String]| {
1307 items
1308 .iter()
1309 .filter(|value| !value.starts_with('-') && plausible_path_token(value))
1310 .cloned()
1311 .collect::<Vec<_>>()
1312 };
1313 match name {
1314 "bash" | "sh" | "zsh" => {
1315 for index in 0..values.len().saturating_sub(1) {
1316 if ["-c", "-lc", "-cl"].contains(&values[index].as_str()) {
1317 rows.extend(shell_file_actions(&values[index + 1], input, depth + 1));
1318 break;
1319 }
1320 }
1321 }
1322 "cp" => {
1323 let paths = paths(&values);
1324 if let Some((target, sources)) = paths.split_last() {
1325 for source in sources {
1326 rows.push((source.clone(), "read".into(), None));
1327 let destination = destination_path(target, source, sources.len() > 1);
1328 rows.push((destination, "create".into(), None));
1329 }
1330 }
1331 }
1332 "mv" => {
1333 let paths = paths(&values);
1334 if let Some((target, sources)) = paths.split_last() {
1335 for source in sources {
1336 rows.push((
1337 destination_path(target, source, sources.len() > 1),
1338 "rename".into(),
1339 Some(source.clone()),
1340 ));
1341 }
1342 }
1343 }
1344 "rm" => rows.extend(
1345 paths(&values)
1346 .into_iter()
1347 .map(|path| (path, "delete".into(), None)),
1348 ),
1349 "touch" | "install" => rows.extend(
1350 paths(&values)
1351 .into_iter()
1352 .map(|path| (path, "create".into(), None)),
1353 ),
1354 "tee" => rows.extend(
1355 paths(&values)
1356 .into_iter()
1357 .map(|path| (path, "write".into(), None)),
1358 ),
1359 "cat" | "head" | "tail" | "nl" | "wc" | "source" | "." => rows.extend(
1360 paths(&values)
1361 .into_iter()
1362 .map(|path| (path, "read".into(), None)),
1363 ),
1364 "sed" => {
1365 let in_place = values.iter().any(|value| {
1366 value == "-i" || value.starts_with("-i") || value.starts_with("--in-place")
1367 });
1368 let mut script_seen = false;
1369 for value in &values {
1370 if value.starts_with('-') {
1371 continue;
1372 }
1373 if !script_seen {
1374 script_seen = true;
1375 } else if plausible_path_token(value) {
1376 rows.push((
1377 value.clone(),
1378 if in_place { "write" } else { "read" }.into(),
1379 None,
1380 ));
1381 }
1382 }
1383 }
1384 "find" => rows.extend(
1385 values
1386 .iter()
1387 .take_while(|value| !value.starts_with('-') && value.as_str() != "!")
1388 .filter(|value| plausible_path_token(value))
1389 .cloned()
1390 .map(|path| (path, "read".into(), None)),
1391 ),
1392 "rg" | "grep" | "jq" => {
1393 let mut expression_seen = values.iter().any(|value| value == "--files");
1394 for value in &values {
1395 if value.starts_with('-') {
1396 continue;
1397 }
1398 if !expression_seen {
1399 expression_seen = true;
1400 } else if plausible_path_token(value) {
1401 rows.push((value.clone(), "read".into(), None));
1402 }
1403 }
1404 }
1405 _ => {}
1406 }
1407 rows
1408}
1409
1410fn destination_path(target: &str, source: &str, multiple: bool) -> String {
1411 if multiple || target.ends_with('/') {
1412 Path::new(target)
1413 .join(Path::new(source).file_name().unwrap_or_default())
1414 .to_string_lossy()
1415 .into_owned()
1416 } else {
1417 target.to_string()
1418 }
1419}
1420
1421fn collect_path_fields(
1422 value: &Value,
1423 access: &str,
1424 out: &mut BTreeMap<String, (String, Option<String>)>,
1425) {
1426 match value {
1427 Value::Object(object) => {
1428 for (key, value) in object {
1429 let key = key.to_ascii_lowercase();
1430 if matches!(
1431 key.as_str(),
1432 "path" | "file_path" | "filepath" | "notebook_path" | "old_path" | "new_path"
1433 ) && let Some(path) = value.as_str()
1434 {
1435 let path = clean_path_token(path);
1436 if !path.is_empty() {
1437 out.insert(path, (access.to_string(), None));
1438 }
1439 } else if value.is_object() || value.is_array() {
1440 collect_path_fields(value, access, out);
1441 }
1442 }
1443 }
1444 Value::Array(values) => {
1445 for value in values {
1446 collect_path_fields(value, access, out);
1447 }
1448 }
1449 _ => {}
1450 }
1451}
1452
1453fn clean_path_token(value: &str) -> String {
1454 value
1455 .trim()
1456 .trim_matches(['"', '\'', '`', ',', ':'])
1457 .trim_start_matches("file://")
1458 .to_string()
1459}
1460
1461fn command_from_tool_input(input: &Value) -> String {
1462 for key in ["cmd", "command", "pattern", "file_path", "path", "text"] {
1463 if let Some(value) = input.get(key).and_then(Value::as_str)
1464 && !value.is_empty()
1465 {
1466 return if key == "pattern" {
1467 format!("search {value}")
1468 } else {
1469 value.to_string()
1470 };
1471 }
1472 }
1473 if input.is_null() {
1474 String::new()
1475 } else {
1476 truncate_clean(&input.to_string(), 300)
1477 }
1478}
1479
1480fn parse_tool_args(value: &Value) -> Value {
1481 if let Some(text) = value.as_str() {
1482 serde_json::from_str(text).unwrap_or_else(|_| serde_json::json!({ "text": text }))
1483 } else {
1484 value.clone()
1485 }
1486}
1487
1488fn status_from_output(output: &str) -> &'static str {
1489 let lowered = output.to_ascii_lowercase();
1490 if lowered.contains("process exited with code 0") || lowered.contains("\"is_error\":false") {
1491 "ok"
1492 } else if lowered.contains("process exited with code")
1493 || lowered.contains("\"is_error\":true")
1494 || lowered.contains("error")
1495 {
1496 "fail"
1497 } else {
1498 "observed"
1499 }
1500}
1501
1502pub fn tool_category(name: &str, command: &str) -> String {
1503 let n = name.to_ascii_lowercase();
1504 if n.ends_with("exec_command") || n == "bash" {
1505 "shell"
1506 } else if ["apply_patch", "edit", "write", "multiedit", "notebookedit"].contains(&n.as_str()) {
1507 "edit"
1508 } else if ["read", "grep", "glob", "ls"].contains(&n.as_str()) {
1509 "read"
1510 } else if n.contains("web")
1511 || n.contains("browser")
1512 || n.contains("search")
1513 || command.contains("http")
1514 {
1515 "network"
1516 } else if n.contains("plan") || n.contains("todo") {
1517 "plan"
1518 } else if n.contains("task") || n.contains("agent") {
1519 "subagent"
1520 } else {
1521 "tool"
1522 }
1523 .to_string()
1524}
1525
1526fn command_effect(command: &str) -> String {
1527 let cmd = basename_from_command(command);
1528 let text = command.to_ascii_lowercase();
1529 if ["cargo", "pytest", "npm", "pnpm", "yarn", "go", "make"].contains(&cmd.as_str())
1530 && any_word(&text, &["test", "check", "build", "clippy"])
1531 {
1532 "test"
1533 } else if cmd == "git"
1534 && any_word(
1535 &text,
1536 &["commit", "push", "add", "checkout", "merge", "rebase"],
1537 )
1538 {
1539 "repo"
1540 } else if ["curl", "wget", "ssh", "scp", "git"].contains(&cmd.as_str())
1541 && (any_word(
1542 &text,
1543 &["clone", "fetch", "pull", "push", "curl", "wget", "ssh"],
1544 ) || text.contains("http://")
1545 || text.contains("https://"))
1546 {
1547 "network"
1548 } else if [
1549 "tee", "cp", "mv", "rm", "mkdir", "touch", "python", "python3", "node", "npm",
1550 ]
1551 .contains(&cmd.as_str())
1552 && (text.contains('>')
1553 || text.contains("--write")
1554 || text.contains(" rm ")
1555 || text.contains(" mkdir ")
1556 || text.contains(" touch ")
1557 || text.contains(" cp ")
1558 || text.contains(" mv "))
1559 {
1560 "write"
1561 } else if [
1562 "rg", "grep", "sed", "cat", "head", "tail", "find", "ls", "nl", "wc", "jq", "git",
1563 ]
1564 .contains(&cmd.as_str())
1565 {
1566 "read"
1567 } else if text.contains("http://")
1568 || text.contains("https://")
1569 || text.contains("crates.io")
1570 || text.contains("github.com")
1571 {
1572 "network"
1573 } else {
1574 "process"
1575 }
1576 .to_string()
1577}
1578
1579fn any_word(text: &str, words: &[&str]) -> bool {
1580 text.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
1581 .any(|part| words.contains(&part))
1582}
1583
1584fn basename_from_command(command: &str) -> String {
1585 let parts = split_shell(command);
1586 let mut idx = 0;
1587 while idx < parts.len()
1588 && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
1589 &Path::new(&parts[idx])
1590 .file_name()
1591 .and_then(|v| v.to_str())
1592 .unwrap_or(""),
1593 )
1594 {
1595 idx += 1;
1596 if idx < parts.len() && parts[idx].starts_with('-') {
1597 idx += 1;
1598 }
1599 }
1600 parts
1601 .get(idx)
1602 .and_then(|part| process_name_from_part(part))
1603 .unwrap_or_else(|| "none".to_string())
1604}
1605
1606pub fn command_process_chain(command: &str) -> Vec<String> {
1607 process_chain_from_parts(&split_shell(command))
1608}
1609
1610fn process_chain_from_parts(parts: &[String]) -> Vec<String> {
1611 if parts.is_empty() {
1612 return Vec::new();
1613 }
1614 let mut idx = 0;
1615 while idx < parts.len()
1616 && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
1617 &Path::new(&parts[idx])
1618 .file_name()
1619 .and_then(|v| v.to_str())
1620 .unwrap_or(""),
1621 )
1622 {
1623 idx += 1;
1624 if idx < parts.len() && parts[idx].starts_with('-') {
1625 idx += 1;
1626 }
1627 }
1628 let Some(proc_name) = parts.get(idx).and_then(|part| process_name_from_part(part)) else {
1629 return Vec::new();
1630 };
1631 let mut chain = vec![proc_name.clone()];
1632 if ["bash", "sh", "zsh"].contains(&proc_name.as_str()) {
1633 for flag_idx in idx + 1..parts.len().saturating_sub(1) {
1634 if ["-c", "-lc", "-cl"].contains(&parts[flag_idx].as_str()) {
1635 chain.extend(command_process_chain(&parts[flag_idx + 1]));
1636 break;
1637 }
1638 }
1639 }
1640 chain
1641}
1642
1643fn process_name_from_part(part: &str) -> Option<String> {
1644 let raw = part.trim_matches(['"', '\'']);
1645 if raw.is_empty() {
1646 return None;
1647 }
1648 let path = Path::new(raw);
1649 let file_name = path.file_name().and_then(|v| v.to_str()).unwrap_or(raw);
1650 let parts = path_component_strings(path);
1651 if looks_like_home_directory(&parts) && parts.len() <= 2 {
1652 return Some("external".to_string());
1653 }
1654 if contains_private_marker(file_name) {
1655 return Some("external".to_string());
1656 }
1657 Some(file_name.to_string())
1658}
1659
1660fn shell_segments(command: &str) -> Vec<Vec<String>> {
1663 fn flush_word(tokens: &mut Vec<String>, current: &mut String) {
1664 if !current.is_empty() {
1665 tokens.push(std::mem::take(current));
1666 }
1667 }
1668 fn flush_segment(segments: &mut Vec<Vec<String>>, tokens: &mut Vec<String>) {
1669 if !tokens.is_empty() {
1670 segments.push(std::mem::take(tokens));
1671 }
1672 }
1673
1674 let command = strip_heredoc_bodies(command);
1675 let mut segments = Vec::new();
1676 let mut tokens = Vec::new();
1677 let mut current = String::new();
1678 let mut quote = None;
1679 let mut escaped = false;
1680 let mut chars = command.chars().peekable();
1681 while let Some(ch) = chars.next() {
1682 if escaped {
1683 current.push(ch);
1684 escaped = false;
1685 } else if ch == '\\' {
1686 escaped = true;
1687 } else if quote == Some(ch) {
1688 quote = None;
1689 } else if quote.is_some() {
1690 current.push(ch);
1691 } else if ch == '\'' || ch == '"' {
1692 quote = Some(ch);
1693 } else if ch == '#' && current.is_empty() {
1694 for next in chars.by_ref() {
1695 if next == '\n' {
1696 flush_segment(&mut segments, &mut tokens);
1697 break;
1698 }
1699 }
1700 } else if ch.is_whitespace() {
1701 flush_word(&mut tokens, &mut current);
1702 if ch == '\n' {
1703 flush_segment(&mut segments, &mut tokens);
1704 }
1705 } else if ch == '&' && chars.peek() == Some(&'>') {
1706 flush_word(&mut tokens, &mut current);
1707 chars.next();
1708 let operator = if chars.peek() == Some(&'>') {
1709 chars.next();
1710 "&>>"
1711 } else {
1712 "&>"
1713 };
1714 tokens.push(operator.into());
1715 } else if matches!(ch, ';' | '|' | '(' | ')') || ch == '&' {
1716 flush_word(&mut tokens, &mut current);
1717 if (ch == '|' || ch == '&') && chars.peek() == Some(&ch) {
1718 chars.next();
1719 }
1720 flush_segment(&mut segments, &mut tokens);
1721 } else if ch == '>' || ch == '<' {
1722 flush_word(&mut tokens, &mut current);
1723 let mut operator = ch.to_string();
1724 while chars.peek() == Some(&ch) && operator.len() < 3 {
1725 operator.push(chars.next().expect("peeked redirection"));
1726 }
1727 tokens.push(operator);
1728 } else {
1729 current.push(ch);
1730 }
1731 }
1732 flush_word(&mut tokens, &mut current);
1733 flush_segment(&mut segments, &mut tokens);
1734 segments
1735}
1736
1737fn strip_heredoc_bodies(command: &str) -> String {
1738 fn delimiters(line: &str) -> Vec<String> {
1739 let bytes = line.as_bytes();
1740 let mut output = Vec::new();
1741 let mut index = 0;
1742 while index + 1 < bytes.len() {
1743 if bytes[index] != b'<' || bytes[index + 1] != b'<' {
1744 index += 1;
1745 continue;
1746 }
1747 index += 2;
1748 if bytes.get(index) == Some(&b'<') {
1749 index += 1;
1750 continue;
1751 }
1752 if bytes.get(index) == Some(&b'-') {
1753 index += 1;
1754 }
1755 while bytes.get(index).is_some_and(u8::is_ascii_whitespace) {
1756 index += 1;
1757 }
1758 let quote = bytes
1759 .get(index)
1760 .copied()
1761 .filter(|value| *value == b'\'' || *value == b'"');
1762 if quote.is_some() {
1763 index += 1;
1764 }
1765 let start = index;
1766 while let Some(value) = bytes.get(index) {
1767 if quote.is_some_and(|quote| *value == quote)
1768 || (quote.is_none()
1769 && (value.is_ascii_whitespace() || b";|&><".contains(value)))
1770 {
1771 break;
1772 }
1773 index += 1;
1774 }
1775 if start < index {
1776 output.push(line[start..index].to_string());
1777 }
1778 }
1779 output
1780 }
1781
1782 let mut pending = VecDeque::<String>::new();
1783 let mut output = Vec::new();
1784 for line in command.lines() {
1785 if let Some(delimiter) = pending.front() {
1786 if line.trim_start_matches('\t').trim_end() == delimiter {
1787 pending.pop_front();
1788 }
1789 continue;
1790 }
1791 output.push(line);
1792 pending.extend(delimiters(line));
1793 }
1794 output.join("\n")
1795}
1796
1797fn is_redirection_token(token: &str) -> bool {
1798 [">", ">>", "&>", "&>>", "<", "<<", "<<<", "<>"].contains(&token)
1799}
1800
1801fn shell_command_index(parts: &[String]) -> Option<usize> {
1802 let mut index = 0;
1803 while index < parts.len() {
1804 let part = parts[index].as_str();
1805 if ["then", "do", "else"].contains(&part)
1806 || part.split_once('=').is_some_and(|(name, _)| {
1807 !name.is_empty()
1808 && name
1809 .chars()
1810 .all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1811 })
1812 {
1813 index += 1;
1814 continue;
1815 }
1816 if ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(&part) {
1817 index += 1;
1818 while index < parts.len() && parts[index].starts_with('-') {
1819 index += 1;
1820 }
1821 continue;
1822 }
1823 return Some(index);
1824 }
1825 None
1826}
1827
1828fn split_shell(command: &str) -> Vec<String> {
1829 let mut parts = Vec::new();
1830 let mut current = String::new();
1831 let mut quote = None;
1832 let mut escaped = false;
1833 for ch in command.chars() {
1834 if escaped {
1835 current.push(ch);
1836 escaped = false;
1837 } else if ch == '\\' {
1838 escaped = true;
1839 } else if quote == Some(ch) {
1840 quote = None;
1841 } else if quote.is_some() {
1842 current.push(ch);
1843 } else if ch == '\'' || ch == '"' {
1844 quote = Some(ch);
1845 } else if ch.is_whitespace() {
1846 if !current.is_empty() {
1847 parts.push(std::mem::take(&mut current));
1848 }
1849 } else {
1850 current.push(ch);
1851 }
1852 }
1853 if !current.is_empty() {
1854 parts.push(current);
1855 }
1856 parts
1857}
1858
1859fn extract_domains(text: &str) -> Vec<String> {
1860 let mut domains = BTreeSet::new();
1861 for part in text.split(|c: char| c.is_whitespace() || ['"', '\'', ')', '('].contains(&c)) {
1862 let stripped = part
1863 .strip_prefix("https://")
1864 .or_else(|| part.strip_prefix("http://"));
1865 if let Some(rest) = stripped
1866 && let Some(domain) = rest.split('/').next()
1867 && !domain.is_empty()
1868 {
1869 domains.insert(domain.to_ascii_lowercase());
1870 }
1871 for known in [
1872 "github.com",
1873 "crates.io",
1874 "huggingface.co",
1875 "hf.co",
1876 "openai.com",
1877 "anthropic.com",
1878 ] {
1879 if part.contains(known) {
1880 domains.insert(known.to_string());
1881 }
1882 }
1883 }
1884 domains.into_iter().collect()
1885}
1886
1887fn extract_path_groups(
1888 project_root: &Path,
1889 name: &str,
1890 input: &Value,
1891 command: &str,
1892) -> Vec<String> {
1893 let mut groups = BTreeSet::new();
1894 if ["write", "edit", "multiedit", "notebookedit", "read"]
1895 .contains(&name.to_ascii_lowercase().as_str())
1896 {
1897 for key in ["file_path", "path"] {
1898 if let Some(path) = input.get(key).and_then(Value::as_str) {
1899 groups.insert(path_group(path, project_root));
1900 }
1901 }
1902 }
1903 for part in split_shell(command) {
1904 if plausible_path_token(&part) {
1905 groups.insert(path_group(&part, project_root));
1906 }
1907 }
1908 groups.into_iter().filter(|v| v != "none").collect()
1909}
1910
1911fn plausible_path_token(part: &str) -> bool {
1912 let part = part.trim_matches(['"', '\'']);
1913 let lower = part.to_ascii_lowercase();
1914 let components = part.split('/').collect::<Vec<_>>();
1915 let looks_like_sed_expression = part.starts_with("s/")
1916 && part.rsplit('/').next().is_some_and(|flags| {
1917 flags.is_empty() || flags.chars().all(|flag| "gimpe".contains(flag))
1918 });
1919 let looks_like_slash_separated_phrase = components.len() >= 3
1920 && components.iter().all(|component| {
1921 component.chars().all(char::is_alphabetic)
1922 && component.chars().next().is_some_and(char::is_uppercase)
1923 });
1924 if part.is_empty()
1925 || part.starts_with('-')
1926 || part.starts_with('$')
1927 || part.starts_with('~')
1928 || part.starts_with("http://")
1929 || part.starts_with("https://")
1930 || lower.starts_with("origin/")
1931 || lower.starts_with("refs/")
1932 || lower.starts_with("repos/")
1933 || part == "HEAD"
1934 || part.starts_with("HEAD.")
1935 || part.contains("...")
1936 || looks_like_slash_separated_phrase
1937 || looks_like_sed_expression
1938 || part.len() > 140
1939 || part.chars().any(char::is_whitespace)
1940 || part.chars().any(|c| "{}()=;<>|`*?[]\"#$,:@^!".contains(c))
1941 {
1942 return false;
1943 }
1944 let suffix = Path::new(part)
1945 .extension()
1946 .and_then(|v| v.to_str())
1947 .unwrap_or("");
1948 part.contains('/')
1949 || [
1950 "rs", "py", "md", "json", "ts", "tsx", "toml", "lock", "js", "c", "h", "svg", "html",
1951 "css",
1952 ]
1953 .contains(&suffix)
1954}
1955
1956pub fn path_group(path: &str, project_root: &Path) -> String {
1957 let path = path.trim_matches(['"', '\'']);
1958 if path.is_empty() {
1959 return "none".to_string();
1960 }
1961 let p = Path::new(path);
1962 let parts = if p.is_absolute() {
1963 if let Ok(rel) = p.strip_prefix(project_root) {
1964 path_component_strings(rel)
1965 } else {
1966 return external_path_group(path, &path_component_strings(p));
1967 }
1968 } else {
1969 let parts = path_component_strings(p);
1970 if let Some(group) = sensitive_relative_path_group(path, &parts) {
1971 return group;
1972 }
1973 parts
1974 };
1975 collapse_project_path(parts)
1976}
1977
1978pub fn path_component_strings(path: &Path) -> Vec<String> {
1979 path.components()
1980 .filter_map(|c| {
1981 let part = c.as_os_str().to_string_lossy();
1982 let part = part.as_ref();
1983 if part == "." || part == "/" || part.is_empty() {
1984 None
1985 } else {
1986 Some(part.to_string())
1987 }
1988 })
1989 .collect()
1990}
1991
1992pub fn collapse_project_path(parts: Vec<String>) -> String {
1993 let parts = parts
1994 .into_iter()
1995 .filter(|part| part != "." && !part.is_empty())
1996 .map(|part| truncate_path_component(&part))
1997 .collect::<Vec<_>>();
1998 if parts.is_empty() {
1999 "repo".to_string()
2000 } else if [
2001 "collector",
2002 "frontend",
2003 "docs",
2004 "bpf",
2005 "agentpprof",
2006 "agent-session",
2007 ]
2008 .contains(&parts[0].as_str())
2009 {
2010 parts.into_iter().take(3).collect::<Vec<_>>().join("/")
2011 } else {
2012 parts.into_iter().take(2).collect::<Vec<_>>().join("/")
2013 }
2014}
2015
2016fn truncate_path_component(part: &str) -> String {
2017 if part.chars().count() > 48 {
2018 format!("{}...", part.chars().take(45).collect::<String>())
2019 } else {
2020 part.to_string()
2021 }
2022}
2023
2024fn external_path_group(raw: &str, parts: &[String]) -> String {
2025 sensitive_relative_path_group(raw, parts).unwrap_or_else(|| "external/path".to_string())
2026}
2027
2028fn sensitive_relative_path_group(raw: &str, parts: &[String]) -> Option<String> {
2029 let lowered = raw.to_ascii_lowercase();
2030 let lower_parts = parts
2031 .iter()
2032 .map(|part| part.to_ascii_lowercase())
2033 .collect::<Vec<_>>();
2034 if lower_parts.iter().any(|part| part == ".codex") {
2035 Some("external/codex".to_string())
2036 } else if lower_parts.iter().any(|part| part == ".claude") {
2037 Some("external/claude".to_string())
2038 } else if lower_parts.first().is_some_and(|part| part == "tmp")
2039 || lowered.contains("/tmp")
2040 || lowered.contains("_/tmp")
2041 || lower_parts
2042 .windows(2)
2043 .any(|window| window[0] == "var" && window[1] == "tmp")
2044 {
2045 Some("external/tmp".to_string())
2046 } else if lowered.starts_with("~/")
2047 || lowered == "~"
2048 || lowered.contains("/home")
2049 || lowered.contains("_/home")
2050 || lowered.contains("-home-")
2051 || lowered.contains("/users")
2052 || lowered.contains("_/users")
2053 || looks_like_home_directory(&lower_parts)
2054 || contains_private_marker(&lowered)
2055 {
2056 Some("external/home".to_string())
2057 } else {
2058 None
2059 }
2060}
2061
2062pub fn looks_like_home_directory(parts: &[String]) -> bool {
2063 parts
2064 .first()
2065 .is_some_and(|part| part == "home" || part == "users")
2066}
2067
2068fn current_username() -> Option<String> {
2069 dirs::home_dir()
2070 .and_then(|home| {
2071 home.file_name()
2072 .map(|part| part.to_string_lossy().to_string())
2073 })
2074 .filter(|name| !name.is_empty())
2075}
2076
2077pub fn contains_private_marker(text: &str) -> bool {
2078 let lowered = text.to_ascii_lowercase();
2079 current_username()
2080 .map(|name| lowered.contains(&name.to_ascii_lowercase()))
2081 .unwrap_or(false)
2082}
2083
2084fn content_to_text(value: &Value) -> String {
2085 match value {
2086 Value::String(s) => s.clone(),
2087 Value::Array(items) => items
2088 .iter()
2089 .filter_map(|item| {
2090 if let Some(text) = item.as_str() {
2091 return Some(text.to_string());
2092 }
2093 let typ = item.get("type").and_then(Value::as_str).unwrap_or("");
2094 if typ == "tool_result" || typ == "tool_use" || typ == "function_call" {
2095 return None;
2096 }
2097 if typ == "thinking" {
2099 return item
2100 .get("thinking")
2101 .and_then(Value::as_str)
2102 .filter(|s| !s.is_empty())
2103 .map(str::to_string);
2104 }
2105 item.get("text")
2106 .or_else(|| item.get("content"))
2107 .and_then(Value::as_str)
2108 .map(str::to_string)
2109 })
2110 .collect::<Vec<_>>()
2111 .join("\n"),
2112 Value::Object(_) => value
2113 .get("text")
2114 .or_else(|| value.get("content"))
2115 .and_then(Value::as_str)
2116 .unwrap_or("")
2117 .to_string(),
2118 _ => String::new(),
2119 }
2120}
2121
2122fn local_session_ids(obj: &Value) -> (Option<String>, Option<String>) {
2123 let session_id = first_json_string(
2124 obj,
2125 &["sessionId", "session_id"],
2126 &["/payload/session_id", "/payload/sessionId"],
2127 );
2128 let conversation_id = first_json_string(
2129 obj,
2130 &["conversation_id", "conversationId", "thread_id", "threadId"],
2131 &[
2132 "/payload/conversation_id",
2133 "/payload/conversationId",
2134 "/payload/thread_id",
2135 "/payload/threadId",
2136 ],
2137 )
2138 .or_else(|| session_id.clone());
2139 (
2140 session_id.or_else(|| conversation_id.clone()),
2141 conversation_id,
2142 )
2143}
2144
2145fn first_json_string(obj: &Value, keys: &[&str], pointers: &[&str]) -> Option<String> {
2146 keys.iter()
2147 .filter_map(|key| obj.get(*key).and_then(Value::as_str))
2148 .chain(
2149 pointers
2150 .iter()
2151 .filter_map(|pointer| obj.pointer(pointer).and_then(Value::as_str)),
2152 )
2153 .find(|value| !value.is_empty())
2154 .map(str::to_string)
2155}
2156
2157fn codex_exec_option_arity(arg: &str) -> Option<usize> {
2158 if arg.contains('=') && arg.starts_with("--") {
2159 return Some(1);
2160 }
2161
2162 match arg {
2163 "--json"
2164 | "--skip-git-repo-check"
2165 | "--ephemeral"
2166 | "--ignore-user-config"
2167 | "--full-auto"
2168 | "--dangerously-bypass-approvals-and-sandbox" => Some(1),
2169 "-C" | "-a" | "-s" | "-m" | "-c" | "-p" | "--cd" | "--model" | "--sandbox"
2170 | "--profile" | "--config" | "--ask-for-approval" | "--approval-policy"
2171 | "--output-format" | "--color" => Some(2),
2172 _ => None,
2173 }
2174}
2175
2176fn shell_words(input: &str) -> Option<Vec<String>> {
2177 let mut words = Vec::new();
2178 let mut current = String::new();
2179 let mut quote = None::<char>;
2180 let mut chars = input.chars().peekable();
2181
2182 while let Some(ch) = chars.next() {
2183 match (quote, ch) {
2184 (None, c) if c.is_whitespace() => {
2185 if !current.is_empty() {
2186 words.push(std::mem::take(&mut current));
2187 }
2188 }
2189 (None, '\'' | '"') => quote = Some(ch),
2190 (Some(q), c) if c == q => quote = None,
2191 (_, '\\') => {
2192 if let Some(next) = chars.next() {
2193 current.push(next);
2194 }
2195 }
2196 _ => current.push(ch),
2197 }
2198 }
2199 if quote.is_some() {
2200 return None;
2201 }
2202 if !current.is_empty() {
2203 words.push(current);
2204 }
2205 Some(words)
2206}
2207
2208fn claude_usage_key(obj: &Value) -> String {
2209 obj.get("requestId")
2210 .or_else(|| obj.pointer("/message/id"))
2211 .or_else(|| obj.get("uuid"))
2212 .and_then(Value::as_str)
2213 .unwrap_or("usage")
2214 .to_string()
2215}
2216
2217fn local_message_preview(value: &Value) -> Option<String> {
2218 let mut parts = Vec::new();
2219 collect_local_text(value, &mut parts);
2220 clean_prompt_text(&parts.join(" "))
2221}
2222
2223fn collect_local_text(value: &Value, out: &mut Vec<String>) {
2224 match value {
2225 Value::String(text) => out.push(text.clone()),
2226 Value::Array(items) => {
2227 for item in items {
2228 collect_local_text(item, out);
2229 }
2230 }
2231 Value::Object(obj) => {
2232 if obj.get("type").and_then(Value::as_str).is_some_and(|typ| {
2233 typ == "tool_use" || typ == "function_call" || typ == "tool_result"
2234 }) {
2235 return;
2236 }
2237 for key in ["text", "content", "message", "input", "prompt"] {
2238 if let Some(value) = obj.get(key) {
2239 collect_local_text(value, out);
2240 }
2241 }
2242 }
2243 _ => {}
2244 }
2245}
2246
2247fn is_claude_tool_result(obj: &Value) -> bool {
2248 obj.get("toolUseResult").is_some()
2249 || obj.get("tool_use_result").is_some()
2250 || obj
2251 .pointer("/message/content")
2252 .and_then(Value::as_array)
2253 .is_some_and(|items| {
2254 items
2255 .iter()
2256 .any(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
2257 })
2258}
2259
2260fn find_file_arg(value: &Value) -> Option<&str> {
2261 match value {
2262 Value::Object(obj) => {
2263 for key in ["file_path", "path", "filepath"] {
2264 if let Some(path) = obj.get(key).and_then(Value::as_str) {
2265 return Some(path);
2266 }
2267 }
2268 obj.values().find_map(find_file_arg)
2269 }
2270 Value::Array(items) => items.iter().find_map(find_file_arg),
2271 _ => None,
2272 }
2273}
2274
2275fn is_noise_path(path: &str) -> bool {
2276 const NOISE: &[&str] = &[
2277 "/.claude/",
2278 "/.codex/",
2279 "/.gemini/",
2280 "/.git/",
2281 "/node_modules/",
2282 "/.npm/",
2283 "/.cache/",
2284 "CLAUDE.md",
2285 "AGENTS.md",
2286 ];
2287 NOISE.iter().any(|pat| path.contains(pat))
2288}
2289
2290fn clean_prompt_text(text: &str) -> Option<String> {
2291 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
2292 let text = text
2293 .strip_prefix("<session>")
2294 .and_then(|text| text.strip_suffix("</session>"))
2295 .unwrap_or(&text)
2296 .trim();
2297 (!text.is_empty()).then(|| text.to_string())
2298}
2299
2300pub fn short_hash(text: &str, n: usize) -> String {
2301 let digest = Sha256::digest(text.as_bytes());
2302 hex::encode(digest).chars().take(n).collect()
2303}
2304
2305pub fn truncate_clean(text: &str, limit: usize) -> String {
2306 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
2307 if text.chars().count() <= limit {
2308 return text;
2309 }
2310 text.chars()
2311 .take(limit.saturating_sub(1))
2312 .collect::<String>()
2313 + "."
2314}
2315
2316pub fn one_word(text: &str, default: &str) -> String {
2317 let mut cur = String::new();
2318 for ch in text.to_ascii_lowercase().chars() {
2319 if ch.is_ascii_alphanumeric() {
2320 cur.push(ch);
2321 } else if cur.len() >= 2 {
2322 break;
2323 } else {
2324 cur.clear();
2325 }
2326 }
2327 if cur.len() >= 2 {
2328 cur
2329 } else {
2330 default.to_string()
2331 }
2332}
2333
2334fn short_session_id(id: &str) -> String {
2335 let id = id.trim();
2336 if id.is_empty() {
2337 return "session".to_string();
2338 }
2339 let compact = id
2340 .rsplit(['/', '\\'])
2341 .next()
2342 .unwrap_or(id)
2343 .trim_end_matches(".jsonl");
2344 const MAX_SESSION_ID_CHARS: usize = 12;
2345 if compact.chars().count() <= MAX_SESSION_ID_CHARS {
2346 return compact.to_string();
2347 }
2348 let head = compact.chars().take(6).collect::<String>();
2349 let tail = compact
2350 .chars()
2351 .rev()
2352 .take(5)
2353 .collect::<Vec<_>>()
2354 .into_iter()
2355 .rev()
2356 .collect::<String>();
2357 format!("{head}.{tail}")
2358}
2359
2360fn json_i64(value: &Value, key: &str) -> i64 {
2361 value.get(key).and_then(Value::as_i64).unwrap_or(0)
2362}
2363
2364fn json_u64(value: &Value, key: &str) -> u64 {
2365 value.get(key).and_then(Value::as_u64).unwrap_or(0)
2366}
2367
2368fn ts_ms_from_event(value: &Value) -> Option<i64> {
2369 value
2370 .get("timestamp")
2371 .and_then(Value::as_str)
2372 .and_then(parse_ts_ms)
2373}
2374
2375fn parse_ts_ms(value: &str) -> Option<i64> {
2376 chrono::DateTime::parse_from_rfc3339(value)
2377 .ok()
2378 .map(|ts| ts.timestamp_millis())
2379}
2380
2381fn iso_ms(value: &str) -> Option<u64> {
2382 chrono::DateTime::parse_from_rfc3339(value)
2383 .ok()
2384 .and_then(|ts| u64::try_from(ts.timestamp_millis()).ok())
2385}
2386
2387fn system_time_ms(value: SystemTime) -> u64 {
2388 value
2389 .duration_since(UNIX_EPOCH)
2390 .unwrap_or_default()
2391 .as_millis() as u64
2392}
2393
2394#[cfg(test)]
2395mod tests {
2396 use super::*;
2397 use serde_json::json;
2398 use std::time::UNIX_EPOCH;
2399
2400 #[test]
2401 fn local_session_ids_keep_distinct_conversation_id() {
2402 assert_eq!(
2403 local_session_ids(&json!({"sessionId": "run", "conversation_id": "conv"})),
2404 (Some("run".to_string()), Some("conv".to_string()))
2405 );
2406 assert_eq!(
2407 local_session_ids(&json!({"payload": {"thread_id": "thread"}})),
2408 (Some("thread".to_string()), Some("thread".to_string()))
2409 );
2410 assert_eq!(
2411 local_session_ids(&json!({"payload": {"model": "gpt"}})),
2412 (None, None)
2413 );
2414 }
2415
2416 #[test]
2417 fn agent_jsonl_events_share_one_ir() {
2418 let codex = concat!(
2419 r#"{"type":"turn_context","payload":{"model":"gpt-5","cwd":"/repo"}}"#,
2420 "\n",
2421 r#"{"type":"event_msg","payload":{"type":"user_message","message":"run tests"}}"#,
2422 "\n",
2423 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
2424 "\n",
2425 r#"{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}}"#,
2426 );
2427 let claude = concat!(
2428 r#"{"type":"user","message":{"content":"check build"}}"#,
2429 "\n",
2430 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}}}"#,
2431 );
2432
2433 for (agent, content, tool, model, tokens) in [
2434 (AGENT_CODEX, codex, "exec_command", "gpt-5", 15),
2435 (AGENT_CLAUDE, claude, "Bash", "claude-opus", 12),
2436 ] {
2437 let session = parse_session_content(
2438 agent,
2439 &PathBuf::from("/tmp/session.jsonl"),
2440 UNIX_EPOCH,
2441 content,
2442 )
2443 .expect("session");
2444 assert_eq!(session.events.tools[0].tool_name, tool);
2445 assert_eq!(session.events.tools[0].category, "shell");
2446 assert_eq!(session.events.llm_responses[0].model, model);
2447 let usage = &session.events.llm_responses[0];
2448 let total = usage
2449 .total_tokens
2450 .max(usage.input_tokens + usage.output_tokens + usage.cache_tokens);
2451 assert_eq!(total, tokens);
2452 }
2453 }
2454
2455 #[test]
2456 fn codex_cumulative_usage_separates_cached_input() {
2457 let content = concat!(
2458 r#"{"type":"turn_context","payload":{"model":"gpt-5.6-sol"}}"#,
2459 "\n",
2460 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}}}}"#,
2461 );
2462
2463 let session = parse_session_content(
2464 AGENT_CODEX,
2465 &PathBuf::from("/tmp/session.jsonl"),
2466 UNIX_EPOCH,
2467 content,
2468 )
2469 .expect("session");
2470
2471 assert_eq!(session.usage.input_tokens, 9_200);
2472 assert_eq!(session.usage.cache_read_tokens, 9_984);
2473 assert_eq!(session.usage.output_tokens, 11);
2474 assert_eq!(session.usage.total_tokens, 19_195);
2475 }
2476
2477 #[test]
2478 fn codex_exec_prompt_handles_latest_cli_options() {
2479 let command = concat!(
2480 "/tmp/tools/bin/codex exec --skip-git-repo-check --ignore-user-config ",
2481 "-c model_provider=\"agentsight-mock\" ",
2482 "-c model_providers.agentsight-mock.name=\"AgentSight Mock\" ",
2483 "--sandbox read-only --model gpt-agentsight-mock ",
2484 "agentsight mock prompt collect this exact text"
2485 );
2486
2487 assert_eq!(
2488 codex_exec_prompt(command).as_deref(),
2489 Some("agentsight mock prompt collect this exact text")
2490 );
2491 }
2492
2493 #[test]
2494 fn file_actions_ignore_patch_and_heredoc_bodies() {
2495 let patch = tool_event_from_input(
2496 Some("/repo"),
2497 Some(1),
2498 0,
2499 "exec",
2500 &json!({"text": r#"const patch = "*** Begin Patch\n*** Update File: src/lib.rs\n+#!/bin/sh\n+docs/not-a-file.md\n*** End Patch"; tools.apply_patch(patch)"#}),
2501 None,
2502 );
2503 assert_eq!(
2504 patch.paths,
2505 vec![ToolPath {
2506 path: "src/lib.rs".into(),
2507 access: "write".into(),
2508 previous_path: None,
2509 }]
2510 );
2511
2512 let heredoc = tool_event_from_input(
2513 Some("/repo"),
2514 Some(1),
2515 0,
2516 "exec_command",
2517 &json!({"cmd": "cat <<'EOF'\n#!/bin/sh\nsrc/not-a-file.rs\nEOF\ncat src/real.rs"}),
2518 None,
2519 );
2520 assert_eq!(heredoc.paths.len(), 1);
2521 assert_eq!(heredoc.paths[0].path, "src/real.rs");
2522 }
2523
2524 #[test]
2525 fn codex_exec_wrapper_projects_nested_shell_actions() {
2526 let event = tool_event_from_input(
2527 Some("/repo"),
2528 Some(1),
2529 0,
2530 "exec",
2531 &json!({"text": r#"const r = await tools.exec_command({"cmd":"cat src/lib.rs && sed -i 's/a/b/' src/main.rs","workdir":"/repo"});"#}),
2532 None,
2533 );
2534 assert_eq!(
2535 event
2536 .paths
2537 .iter()
2538 .map(|path| (path.path.as_str(), path.access.as_str()))
2539 .collect::<Vec<_>>(),
2540 vec![("/repo/src/lib.rs", "read"), ("/repo/src/main.rs", "write")]
2541 );
2542 }
2543
2544 #[test]
2545 fn file_actions_are_conservative_for_unknown_and_write_tools() {
2546 let unknown = tool_event_from_input(
2547 Some("/repo"),
2548 Some(1),
2549 0,
2550 "mcp_resource",
2551 &json!({"path": "src/not-a-file.rs"}),
2552 None,
2553 );
2554 assert!(unknown.paths.is_empty());
2555
2556 let write = tool_event_from_input(
2557 Some("/repo"),
2558 Some(1),
2559 0,
2560 "Write",
2561 &json!({"file_path": "src/existing.rs", "content": "changed"}),
2562 None,
2563 );
2564 assert_eq!(write.paths[0].access, "write");
2565 }
2566
2567 #[test]
2568 fn patch_move_keeps_the_immediately_preceding_source() {
2569 let event = tool_event_from_input(
2570 Some("/repo"),
2571 Some(1),
2572 0,
2573 "apply_patch",
2574 &json!({"patch": "*** Begin Patch\n*** Update File: src/a.rs\n*** Move to: src/b.rs\n*** Update File: src/c.rs\n*** End Patch"}),
2575 None,
2576 );
2577 assert!(event.paths.contains(&ToolPath {
2578 path: "src/b.rs".into(),
2579 access: "rename".into(),
2580 previous_path: Some("src/a.rs".into()),
2581 }));
2582 assert!(event.paths.contains(&ToolPath {
2583 path: "src/c.rs".into(),
2584 access: "write".into(),
2585 previous_path: None,
2586 }));
2587
2588 let event = tool_event_from_input(
2589 Some("/repo"),
2590 Some(1),
2591 0,
2592 "apply_patch",
2593 &json!({"patch": "*** Begin Patch\n*** Update File: a.rs\n*** Move to: x.rs\n*** Update File: b.rs\n*** Move to: y.rs\n*** End Patch"}),
2594 None,
2595 );
2596 assert_eq!(
2597 event
2598 .paths
2599 .iter()
2600 .map(|row| (row.path.as_str(), row.previous_path.as_deref()))
2601 .collect::<Vec<_>>(),
2602 vec![("x.rs", Some("a.rs")), ("y.rs", Some("b.rs"))]
2603 );
2604 }
2605
2606 #[test]
2607 fn tool_outputs_mark_failed_file_actions() {
2608 let content = concat!(
2609 r#"{"type":"turn_context","payload":{"cwd":"/repo"}}"#,
2610 "\n",
2611 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"rm src/lib.rs\"}"}}"#,
2612 "\n",
2613 r#"{"type":"response_item","payload":{"type":"function_call_output","call_id":"c1","output":"Process exited with code 1"}}"#,
2614 );
2615 let session = parse_session_content(
2616 AGENT_CODEX,
2617 Path::new("/tmp/session.jsonl"),
2618 UNIX_EPOCH,
2619 content,
2620 )
2621 .expect("session");
2622 assert_eq!(session.events.tools[0].status, "fail");
2623 assert_eq!(session.events.tools[0].paths[0].access, "delete");
2624
2625 let claude = concat!(
2626 r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t0","name":"Read","input":{"file_path":"src/main.rs"}},{"type":"tool_use","id":"t1","name":"Edit","input":{"file_path":"src/lib.rs"}}]}}"#,
2627 "\n",
2628 r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t0","is_error":false,"content":"ok"},{"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"failed"}]}}"#,
2629 );
2630 let gemini = r#"{"messages":[{"type":"gemini","timestamp":"2026-01-01T00:00:00Z","toolCalls":[{"id":"t1","name":"write_file","args":{"file_path":"src/lib.rs"},"status":"error"}]}]}"#;
2631 for (agent, content, expected) in [
2632 (AGENT_CLAUDE, claude, &["ok", "fail"][..]),
2633 (AGENT_GEMINI, gemini, &["fail"][..]),
2634 ] {
2635 let session =
2636 parse_session_content(agent, Path::new("/tmp/session.jsonl"), UNIX_EPOCH, content)
2637 .unwrap();
2638 let statuses = session
2639 .events
2640 .tools
2641 .iter()
2642 .map(|row| row.status.as_str())
2643 .collect::<Vec<_>>();
2644 assert_eq!(statuses, expected);
2645 }
2646 }
2647}