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, EditSummary, LlmResponse, PathReference, SessionCandidate, SessionDirStat,
15 SessionEvents, TokenUsage, 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 if let Some(ts_ms) = iso_ms(ts) {
264 let first_timestamp = acc.start_timestamp_ms.is_none();
265 acc.start_timestamp_ms = Some(
266 acc.start_timestamp_ms
267 .map_or(ts_ms, |start| start.min(ts_ms)),
268 );
269 acc.end_timestamp_ms = Some(if first_timestamp {
270 ts_ms
271 } else {
272 acc.end_timestamp_ms.map_or(ts_ms, |end| end.max(ts_ms))
273 });
274 }
275 }
276 let typ = obj.get("type").and_then(Value::as_str).unwrap_or("");
277 match (agent, typ) {
278 (AGENT_CLAUDE, "result") => {
279 acc.duration_ms = json_u64(&obj, "duration_ms");
280 if let Some(model_usage) = obj.get("modelUsage").and_then(Value::as_object) {
281 for (name, usage) in model_usage {
282 acc.model.get_or_insert_with(|| name.clone());
283 acc.add_usage(
284 name,
285 json_i64(usage, "inputTokens"),
286 json_i64(usage, "outputTokens"),
287 json_i64(usage, "cacheCreationInputTokens"),
288 json_i64(usage, "cacheReadInputTokens"),
289 0,
290 );
291 }
292 }
293 }
294 (AGENT_CLAUDE, "assistant") => {
295 if let Some(name) = obj.pointer("/message/model").and_then(Value::as_str) {
296 acc.model.get_or_insert_with(|| name.to_string());
297 }
298 let model = obj
299 .pointer("/message/model")
300 .and_then(Value::as_str)
301 .or(acc.model.as_deref())
302 .unwrap_or(AGENT_CLAUDE)
303 .to_string();
304 if let Some(usage) = obj.pointer("/message/usage")
305 && claude_seen_usage.insert(claude_usage_key(&obj))
306 {
307 let name = obj
308 .pointer("/message/model")
309 .and_then(Value::as_str)
310 .unwrap_or("unknown");
311 add_usage(
312 &mut claude_message_models,
313 name,
314 json_i64(usage, "input_tokens"),
315 json_i64(usage, "output_tokens"),
316 json_i64(usage, "cache_creation_input_tokens"),
317 json_i64(usage, "cache_read_input_tokens"),
318 0,
319 );
320 }
321 let content = obj.pointer("/message/content").unwrap_or(&Value::Null);
322 if let Some(items) = content.as_array() {
323 for item in items
324 .iter()
325 .filter(|item| item.get("type").and_then(Value::as_str) == Some("tool_use"))
326 {
327 let name = item.get("name").and_then(Value::as_str).unwrap_or("?");
328 acc.add_tool(name);
329 if let Some(fp) = item
330 .pointer("/input/file_path")
331 .and_then(Value::as_str)
332 .filter(|s| !is_noise_path(s))
333 {
334 acc.add_file(fp);
335 }
336 let call_id = item.get("id").and_then(Value::as_str).map(str::to_string);
337 let event = tool_event_from_input(
338 acc.cwd.as_deref(),
339 ts_ms_from_event(&obj),
340 current_prompt_index,
341 name,
342 item.get("input").unwrap_or(&Value::Null),
343 call_id.clone(),
344 );
345 if let Some(id) = call_id {
346 call_index.insert(id, events.tools.len());
347 }
348 events.tools.push(event);
349 }
350 }
351 let text = content_to_text(content);
352 let usage = obj.pointer("/message/usage").unwrap_or(&Value::Null);
353 if !text.trim().is_empty() || usage.is_object() {
354 let preview_text = if !text.trim().is_empty() {
356 text.clone()
357 } else if let Some(items) = content.as_array() {
358 let tool_names: Vec<_> = items
359 .iter()
360 .filter_map(|item| {
361 if item.get("type").and_then(Value::as_str) == Some("tool_use") {
362 item.get("name").and_then(Value::as_str)
363 } else {
364 None
365 }
366 })
367 .collect();
368 if tool_names.is_empty() {
369 String::new()
370 } else {
371 format!("tool: {}", tool_names.join(", "))
372 }
373 } else {
374 String::new()
375 };
376 events.llm_responses.push(LlmResponse {
377 ts_ms: ts_ms_from_event(&obj),
378 prompt_index: current_prompt_index,
379 model,
380 text_hash: short_hash(&(text.clone() + &usage.to_string()), 12),
381 preview: truncate_clean(
382 if preview_text.is_empty() {
383 "token report"
384 } else {
385 &preview_text
386 },
387 140,
388 ),
389 input_tokens: json_u64(usage, "input_tokens"),
390 output_tokens: json_u64(usage, "output_tokens"),
391 cache_tokens: json_u64(usage, "cache_creation_input_tokens")
392 + json_u64(usage, "cache_read_input_tokens"),
393 total_tokens: 0,
394 tag: String::new(),
395 });
396 }
397 }
398 (AGENT_CLAUDE, "queue-operation") if acc.prompt_preview.is_none() => {
399 if obj.get("operation").and_then(Value::as_str) == Some("enqueue")
400 && let Some(text) = obj.get("content").and_then(Value::as_str)
401 && let Some(text) = clean_prompt_text(text)
402 {
403 acc.prompt_preview = Some(text.clone());
404 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
405 }
406 }
407 (AGENT_CLAUDE, "last-prompt") if acc.prompt_preview.is_none() => {
408 if let Some(text) = obj.get("lastPrompt").and_then(Value::as_str)
409 && let Some(text) = clean_prompt_text(text)
410 {
411 acc.prompt_preview = Some(text.clone());
412 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
413 }
414 }
415 (AGENT_CLAUDE, "user") => {
416 let content = obj.pointer("/message/content").unwrap_or(&Value::Null);
417 if claude_is_tool_result(content) || is_claude_tool_result(&obj) {
418 let is_error = obj
419 .get("toolUseResult")
420 .and_then(|v| v.get("is_error"))
421 .and_then(Value::as_bool)
422 .unwrap_or(false);
423 for id in claude_tool_result_ids(content) {
424 if let Some(index) = call_index.get(&id).copied()
425 && let Some(tool) = events.tools.get_mut(index)
426 {
427 tool.status = if is_error { "fail" } else { "ok" }.to_string();
428 }
429 }
430 } else if let Some(text) = local_message_preview(content) {
431 if acc.prompt_preview.is_none() {
432 acc.prompt_preview = Some(text.clone());
433 }
434 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
435 }
436 }
437 (AGENT_CLAUDE, "last-prompt") => {
438 if let Some(text) = obj.get("lastPrompt").and_then(Value::as_str)
439 && let Some(text) = clean_prompt_text(text)
440 {
441 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
442 }
443 }
444 (AGENT_CODEX, "turn_context") => {
445 if let Some(name) = obj.pointer("/payload/model").and_then(Value::as_str) {
446 codex_model = name.to_string();
447 acc.model = Some(name.to_string());
448 }
449 }
450 (AGENT_CODEX, "event_msg") => {
451 let payload = obj.get("payload").unwrap_or(&Value::Null);
452 let ptype = payload.get("type").and_then(Value::as_str).unwrap_or("");
453 if ptype == "patch_apply_end" {
454 let call_id = payload
455 .get("call_id")
456 .and_then(Value::as_str)
457 .map(str::to_string);
458 let mut event = tool_event_from_input(
459 acc.cwd.as_deref(),
460 ts_ms_from_event(&obj),
461 current_prompt_index,
462 "apply_patch",
463 payload,
464 call_id,
465 );
466 event.status = if payload
467 .get("success")
468 .and_then(Value::as_bool)
469 .unwrap_or(false)
470 {
471 "success"
472 } else {
473 "fail"
474 }
475 .to_string();
476 if let Some(index) = event
477 .call_id
478 .as_deref()
479 .and_then(|call_id| call_index.get(call_id))
480 .copied()
481 {
482 if let Some(existing) = events.tools.get_mut(index) {
483 existing.status = event.status;
484 if existing.path_refs.is_empty() {
485 existing.path_refs = event.path_refs;
486 }
487 if existing.edit_summary.is_none() {
488 existing.edit_summary = event.edit_summary;
489 }
490 }
491 } else {
492 acc.add_tool("apply_patch");
493 if let Some(call_id) = event.call_id.clone() {
494 call_index.insert(call_id, events.tools.len());
495 }
496 events.tools.push(event);
497 }
498 }
499 if ptype == "token_count"
500 && let Some(usage) = payload.pointer("/info/total_token_usage")
501 {
502 let name = if codex_model.is_empty() {
503 "unknown"
504 } else {
505 &codex_model
506 };
507 let raw_input = json_i64(usage, "input_tokens").max(0);
508 let output = json_i64(usage, "output_tokens").max(0);
509 let cache = json_i64(usage, "cached_input_tokens").max(0);
510 let input = codex_uncached_input(raw_input, cache);
511 acc.set_usage(name, input, output, 0, cache, input + output);
512 }
513 if matches!(ptype, "token_count" | "token_usage") {
514 let info = payload
515 .get("info")
516 .or_else(|| payload.get("usage"))
517 .unwrap_or(payload);
518 let total_usage = info.get("total_token_usage");
519 let token_usage = info.get("last_token_usage").or(total_usage).unwrap_or(info);
520 let is_cumulative_usage =
521 total_usage.is_some() && info.get("last_token_usage").is_none();
522 let raw_input_tokens = json_u64(token_usage, "input_tokens");
523 let output_tokens = json_u64(token_usage, "output_tokens");
524 let cache_tokens = json_u64(token_usage, "cached_input_tokens");
525 let uncached_input_tokens = raw_input_tokens.saturating_sub(cache_tokens);
526 let input_tokens = if is_cumulative_usage {
527 uncached_input_tokens
528 } else {
529 raw_input_tokens
530 };
531 let raw_total_tokens = json_u64(token_usage, "total_tokens")
532 .max(json_u64(info, "total_tokens"))
533 .max(json_u64(info, "tokens"));
534 let total_tokens = if is_cumulative_usage {
535 uncached_input_tokens + output_tokens
536 } else {
537 raw_total_tokens
538 };
539 if total_tokens > 0 {
540 if let Some(last) = events.llm_responses.last_mut()
541 && last.total_tokens == 0
542 {
543 last.input_tokens = input_tokens;
544 last.output_tokens = output_tokens;
545 last.cache_tokens = cache_tokens;
546 last.total_tokens = total_tokens;
547 continue;
548 }
549 if let Some(last_usage) =
550 events.llm_responses.iter_mut().rev().find(|response| {
551 response.prompt_index == current_prompt_index
552 && response.tag == "usage"
553 })
554 {
555 last_usage.ts_ms = ts_ms_from_event(&obj);
556 last_usage.input_tokens = input_tokens;
557 last_usage.output_tokens = output_tokens;
558 last_usage.cache_tokens = cache_tokens;
559 last_usage.total_tokens = total_tokens;
560 continue;
561 }
562 events.llm_responses.push(LlmResponse {
563 ts_ms: ts_ms_from_event(&obj),
564 prompt_index: current_prompt_index,
565 model: if codex_model.is_empty() {
566 AGENT_CODEX.to_string()
567 } else {
568 codex_model.clone()
569 },
570 text_hash: short_hash(&token_usage.to_string(), 12),
571 preview: "token report".to_string(),
572 input_tokens,
573 output_tokens,
574 cache_tokens,
575 total_tokens,
576 tag: "usage".to_string(),
577 });
578 }
579 }
580 if ptype == "user_message" {
581 let text = payload
582 .get("message")
583 .or_else(|| payload.get("content"))
584 .and_then(Value::as_str)
585 .unwrap_or("");
586 if let Some(text) = clean_prompt_text(text) {
587 acc.prompt_preview = Some(text.clone());
588 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
589 }
590 }
591 if ptype == "agent_message" {
592 let text = payload
593 .get("message")
594 .or_else(|| payload.get("content"))
595 .and_then(Value::as_str)
596 .unwrap_or("");
597 if let Some(text) = clean_prompt_text(text) {
598 events.llm_responses.push(LlmResponse {
599 ts_ms: ts_ms_from_event(&obj),
600 prompt_index: current_prompt_index,
601 model: if codex_model.is_empty() {
602 AGENT_CODEX.to_string()
603 } else {
604 codex_model.clone()
605 },
606 text_hash: short_hash(&text, 12),
607 preview: truncate_clean(&text, 180),
608 input_tokens: 0,
609 output_tokens: 0,
610 cache_tokens: 0,
611 total_tokens: 0,
612 tag: String::new(),
613 });
614 }
615 }
616 }
617 (AGENT_CODEX, "response_item")
618 if matches!(
619 obj.pointer("/payload/type").and_then(Value::as_str),
620 Some("function_call" | "custom_tool_call")
621 ) =>
622 {
623 let name = obj
624 .pointer("/payload/name")
625 .and_then(Value::as_str)
626 .unwrap_or("?");
627 acc.add_tool(name);
628 let payload = obj.get("payload").unwrap_or(&Value::Null);
629 let args = parse_tool_args(payload.get("arguments").unwrap_or(&Value::Null));
630 let call_id = payload
631 .get("call_id")
632 .and_then(Value::as_str)
633 .map(str::to_string);
634 let event = tool_event_from_input(
635 acc.cwd.as_deref(),
636 ts_ms_from_event(&obj),
637 current_prompt_index,
638 name,
639 &args,
640 call_id.clone(),
641 );
642 if let Some(id) = call_id {
643 call_index.insert(id, events.tools.len());
644 }
645 events.tools.push(event);
646 }
647 (AGENT_CODEX, "response_item")
648 if matches!(
649 obj.pointer("/payload/type").and_then(Value::as_str),
650 Some("function_call_output" | "custom_tool_call_output")
651 ) =>
652 {
653 if let Some(call_id) = obj.pointer("/payload/call_id").and_then(Value::as_str)
654 && let Some(index) = call_index.get(call_id).copied()
655 && let Some(tool) = events.tools.get_mut(index)
656 {
657 let output = obj
658 .pointer("/payload/output")
659 .and_then(Value::as_str)
660 .unwrap_or("");
661 tool.status = status_from_output(output).to_string();
662 }
663 }
664 (AGENT_CODEX, "response_item")
665 if obj.pointer("/payload/type").and_then(Value::as_str) == Some("message") =>
666 {
667 let payload = obj.get("payload").unwrap_or(&Value::Null);
668 let text = payload
669 .get("message")
670 .or_else(|| payload.get("content"))
671 .and_then(Value::as_str)
672 .unwrap_or("");
673 if let Some(text) = clean_prompt_text(text) {
674 events.llm_responses.push(LlmResponse {
675 ts_ms: ts_ms_from_event(&obj),
676 prompt_index: current_prompt_index,
677 model: if codex_model.is_empty() {
678 AGENT_CODEX.to_string()
679 } else {
680 codex_model.clone()
681 },
682 text_hash: short_hash(&text, 12),
683 preview: truncate_clean(&text, 180),
684 input_tokens: 0,
685 output_tokens: 0,
686 cache_tokens: 0,
687 total_tokens: 0,
688 tag: String::new(),
689 });
690 }
691 }
692 (AGENT_CODEX, "message" | "input" | "user") => {
693 if let Some(text) = local_message_preview(&obj) {
694 acc.prompt_preview = Some(text.clone());
695 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
696 }
697 }
698 _ if acc.prompt_preview.is_none() && typ.contains("user") => {
699 if let Some(text) = local_message_preview(&obj) {
700 acc.prompt_preview = Some(text.clone());
701 current_prompt_index = events.upsert_prompt(ts_ms_from_event(&obj), &text);
702 }
703 }
704 _ => {}
705 }
706 }
707
708 if acc.model_usage.is_empty() {
709 acc.model_usage = claude_message_models;
710 }
711 acc.finish_with_events(events)
712}
713
714fn parse_gemini_json(path: &Path, updated: SystemTime, content: &str) -> Option<AgentSession> {
715 let root: Value = serde_json::from_str(content).ok()?;
716 let mut acc = SessionAccumulator::new(AGENT_GEMINI, path, updated);
717 let mut events = SessionEvents::default();
718 let mut current_prompt_index = 0usize;
719 acc.project_hash = root
720 .get("projectHash")
721 .and_then(Value::as_str)
722 .map(str::to_string);
723 if let Some(id) = root.get("sessionId").and_then(Value::as_str) {
724 acc.session_id = id.to_string();
725 acc.conversation_id = Some(id.to_string());
726 }
727 acc.start_timestamp_ms = root
728 .get("startTime")
729 .and_then(Value::as_str)
730 .and_then(iso_ms);
731 acc.end_timestamp_ms = root
732 .get("lastUpdated")
733 .and_then(Value::as_str)
734 .and_then(iso_ms)
735 .or(acc.start_timestamp_ms);
736 acc.duration_ms = acc
737 .start_timestamp_ms
738 .zip(acc.end_timestamp_ms)
739 .map(|(start, end)| end.saturating_sub(start))
740 .unwrap_or_default();
741
742 let Some(messages) = root.get("messages").and_then(Value::as_array) else {
743 return acc.finish_with_events(events);
744 };
745 for msg in messages {
746 if let Some(ts) = msg.get("timestamp").and_then(Value::as_str) {
747 acc.last_message_at = Some(ts.to_string());
748 }
749 let ts_ms = msg
750 .get("timestamp")
751 .and_then(Value::as_str)
752 .and_then(parse_ts_ms);
753 match msg.get("type").and_then(Value::as_str) {
754 Some("user") if acc.prompt_preview.is_none() => {
755 if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
756 acc.prompt_preview = Some(text.clone());
757 current_prompt_index = events.upsert_prompt(ts_ms, &text);
758 }
759 }
760 Some("user") => {
761 if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
762 current_prompt_index = events.upsert_prompt(ts_ms, &text);
763 }
764 }
765 Some("gemini") | Some("assistant") | Some("model") => {
766 let mut llm_model = AGENT_GEMINI.to_string();
767 if let Some(model) = msg.get("model").and_then(Value::as_str) {
768 llm_model = model.to_string();
769 acc.model.get_or_insert_with(|| model.to_string());
770 if let Some(tokens) = msg.get("tokens") {
771 acc.add_usage(
772 model,
773 json_i64(tokens, "input"),
774 json_i64(tokens, "output"),
775 0,
776 json_i64(tokens, "cached"),
777 json_i64(tokens, "total"),
778 );
779 }
780 }
781 if let Some(tool_calls) = msg.get("toolCalls").and_then(Value::as_array) {
782 for call in tool_calls {
783 let name = call.get("name").and_then(Value::as_str).unwrap_or("?");
784 acc.add_tool(name);
785 if let Some(path) = find_file_arg(call).filter(|path| !is_noise_path(path))
786 {
787 acc.add_file(path);
788 }
789 let tool_input = call.get("args").unwrap_or(call);
790 let mut event = tool_event_from_input(
791 acc.cwd.as_deref(),
792 ts_ms,
793 current_prompt_index,
794 name,
795 tool_input,
796 call.get("id").and_then(Value::as_str).map(str::to_string),
797 );
798 if let Some(status) = call.get("status").and_then(Value::as_str) {
799 event.status = status.to_ascii_lowercase();
800 }
801 events.tools.push(event);
802 }
803 }
804 let content = msg.get("content").unwrap_or(msg);
805 let text = content_to_text(content);
806 let tokens = msg.get("tokens").unwrap_or(&Value::Null);
807 if !text.trim().is_empty() || tokens.is_object() {
808 events.llm_responses.push(LlmResponse {
809 ts_ms,
810 prompt_index: current_prompt_index,
811 model: llm_model,
812 text_hash: short_hash(&(text.clone() + &tokens.to_string()), 12),
813 preview: truncate_clean(
814 if text.trim().is_empty() {
815 "gemini response"
816 } else {
817 &text
818 },
819 140,
820 ),
821 input_tokens: json_u64(tokens, "input"),
822 output_tokens: json_u64(tokens, "output"),
823 cache_tokens: json_u64(tokens, "cached"),
824 total_tokens: json_u64(tokens, "total"),
825 tag: String::new(),
826 });
827 }
828 }
829 _ => {}
830 }
831 }
832 acc.finish_with_events(events)
833}
834
835struct SessionAccumulator {
836 agent_type: String,
837 session_id: String,
838 conversation_id: Option<String>,
839 path: PathBuf,
840 updated: SystemTime,
841 start_timestamp_ms: Option<u64>,
842 end_timestamp_ms: Option<u64>,
843 model: Option<String>,
844 model_usage: BTreeMap<String, TokenUsage>,
845 tools: BTreeMap<String, usize>,
846 files: BTreeMap<String, usize>,
847 prompt_preview: Option<String>,
848 duration_ms: u64,
849 cwd: Option<String>,
850 project_hash: Option<String>,
851 last_message_at: Option<String>,
852}
853
854impl SessionAccumulator {
855 fn new(agent: &str, path: &Path, updated: SystemTime) -> Self {
856 let normalized = normalize_session_log_path(path);
857 let session_id = path
858 .file_stem()
859 .and_then(|stem| stem.to_str())
860 .unwrap_or("session")
861 .to_string();
862 Self {
863 agent_type: agent.to_string(),
864 session_id,
865 conversation_id: None,
866 path: normalized.clone(),
867 updated,
868 start_timestamp_ms: None,
869 end_timestamp_ms: Some(system_time_ms(updated)),
870 model: None,
871 model_usage: BTreeMap::new(),
872 tools: BTreeMap::new(),
873 files: BTreeMap::new(),
874 prompt_preview: None,
875 duration_ms: 0,
876 cwd: None,
877 project_hash: None,
878 last_message_at: None,
879 }
880 }
881
882 fn add_usage(
883 &mut self,
884 model: &str,
885 input: i64,
886 output: i64,
887 cache_creation: i64,
888 cache_read: i64,
889 total: i64,
890 ) {
891 add_usage(
892 &mut self.model_usage,
893 model,
894 input,
895 output,
896 cache_creation,
897 cache_read,
898 total,
899 );
900 }
901
902 fn set_usage(
903 &mut self,
904 model: &str,
905 input: i64,
906 output: i64,
907 cache_creation: i64,
908 cache_read: i64,
909 total: i64,
910 ) {
911 let mut usage = TokenUsage::default();
912 usage.add(input, output, cache_creation, cache_read, total);
913 self.model_usage.insert(model.to_string(), usage);
914 }
915
916 fn add_tool(&mut self, name: &str) {
917 *self.tools.entry(name.to_string()).or_default() += 1;
918 }
919
920 fn add_file(&mut self, path: &str) {
921 *self.files.entry(path.to_string()).or_default() += 1;
922 }
923
924 fn finish(self) -> Option<AgentSession> {
925 let token_usage =
926 self.model_usage
927 .values()
928 .fold(TokenUsage::default(), |mut total, usage| {
929 total.input_tokens += usage.input_tokens;
930 total.output_tokens += usage.output_tokens;
931 total.cache_creation_tokens += usage.cache_creation_tokens;
932 total.cache_read_tokens += usage.cache_read_tokens;
933 total.total_tokens += usage.total_tokens;
934 total
935 });
936 if token_usage.total_tokens == 0
937 && self.tools.is_empty()
938 && self.prompt_preview.is_none()
939 && self.model.is_none()
940 {
941 return None;
942 }
943 let display_id = format!("{}:{}", self.agent_type, short_session_id(&self.session_id));
944 Some(AgentSession {
945 agent_type: self.agent_type,
946 session_id: self.session_id,
947 conversation_id: self.conversation_id,
948 display_id,
949 path: self.path,
950 updated: self.updated,
951 start_timestamp_ms: self
952 .start_timestamp_ms
953 .or_else(|| Some(system_time_ms(self.updated).saturating_sub(self.duration_ms))),
954 end_timestamp_ms: self.end_timestamp_ms,
955 model: self.model,
956 usage: token_usage,
957 model_usage: self.model_usage,
958 tools: self.tools,
959 files: self.files,
960 prompt_preview: self.prompt_preview,
961 duration_ms: self.duration_ms,
962 cwd: self.cwd,
963 project_hash: self.project_hash,
964 last_message_at: self.last_message_at,
965 events: SessionEvents::default(),
966 })
967 }
968
969 fn finish_with_events(self, events: SessionEvents) -> Option<AgentSession> {
970 self.finish().map(|mut session| {
971 session.events = events;
972 session
973 })
974 }
975}
976
977fn walk_agent_files(agent: &'static str, dir: &Path, f: &mut dyn FnMut(&Path, &fs::Metadata)) {
982 let Ok(entries) = fs::read_dir(dir) else {
983 return;
984 };
985 for entry in entries.flatten() {
986 let path = entry.path();
987 if path.is_dir() {
988 walk_agent_files(agent, &path, f);
989 } else if is_agent_file_for(agent, &path)
990 && let Ok(meta) = path.metadata()
991 {
992 f(&path, &meta);
993 }
994 }
995}
996
997fn is_agent_session_file(path: &Path) -> bool {
998 agent_source_for_path(path).is_some()
999}
1000
1001fn is_agent_file_for(agent: &str, path: &Path) -> bool {
1002 match agent {
1003 AGENT_CLAUDE | AGENT_CODEX => {
1004 path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
1005 }
1006 AGENT_GEMINI => {
1007 path.extension().and_then(|ext| ext.to_str()) == Some("json")
1008 && path
1009 .file_name()
1010 .and_then(|name| name.to_str())
1011 .is_some_and(|name| name.starts_with("session-"))
1012 && path.to_string_lossy().contains("/chats/")
1013 }
1014 _ => false,
1015 }
1016}
1017
1018pub(crate) fn user_home_dir() -> Option<PathBuf> {
1019 std::env::var("SUDO_USER")
1020 .ok()
1021 .and_then(|user| {
1022 fs::read_to_string("/etc/passwd").ok().and_then(|passwd| {
1023 passwd
1024 .lines()
1025 .find(|line| line.starts_with(&format!("{user}:")))
1026 .and_then(|line| line.split(':').nth(5))
1027 .map(PathBuf::from)
1028 })
1029 })
1030 .or_else(dirs::home_dir)
1031}
1032
1033fn add_usage(
1034 models: &mut BTreeMap<String, TokenUsage>,
1035 model: &str,
1036 input: i64,
1037 output: i64,
1038 cache_creation: i64,
1039 cache_read: i64,
1040 total: i64,
1041) {
1042 models.entry(model.to_string()).or_default().add(
1043 input,
1044 output,
1045 cache_creation,
1046 cache_read,
1047 total,
1048 );
1049}
1050
1051impl SessionEvents {
1052 fn upsert_prompt(&mut self, ts_ms: Option<i64>, text: &str) -> usize {
1053 let hash = short_hash(text, 12);
1054 if let Some(existing) = self
1055 .prompts
1056 .iter()
1057 .position(|prompt| prompt.text_hash == hash)
1058 {
1059 return existing;
1060 }
1061 let index = self.prompts.len();
1062 self.prompts.push(UserPrompt {
1063 index,
1064 ts_ms,
1065 text_hash: hash,
1066 preview: truncate_clean(text, 180),
1067 tag: String::new(),
1068 });
1069 index
1070 }
1071}
1072
1073fn tool_event_from_input(
1074 cwd: Option<&str>,
1075 ts_ms: Option<i64>,
1076 prompt_index: usize,
1077 name: &str,
1078 input: &Value,
1079 call_id: Option<String>,
1080) -> ToolEvent {
1081 let command = command_from_tool_input(input);
1082 let category = tool_category(name, &command);
1083 let domains = extract_domains(&command);
1084 let command_name = if category == "shell" {
1085 basename_from_command(&command)
1086 } else if category == "network" && !domains.is_empty() {
1087 domains[0]
1088 .split(':')
1089 .next()
1090 .unwrap_or("network")
1091 .to_string()
1092 } else {
1093 one_word(name, "tool")
1094 };
1095 let lower_name = name.to_ascii_lowercase();
1096 let effect = if lower_name == "apply_patch"
1097 || lower_name.contains("write")
1098 || lower_name.contains("edit")
1099 || lower_name.contains("replace")
1100 || command.contains("*** ")
1101 {
1102 "write".to_string()
1103 } else if lower_name.contains("read")
1104 || lower_name.contains("glob")
1105 || lower_name.contains("grep")
1106 || lower_name.contains("search")
1107 {
1108 "read".to_string()
1109 } else {
1110 command_effect(&command)
1111 };
1112 let cwd = cwd.unwrap_or("");
1113 let path_groups = extract_path_groups(Path::new(cwd), name, input, &command);
1114 let path_refs = extract_path_references(Path::new(cwd), name, input, &command, &effect);
1115 let edit_summary = edit_summary_from_input(name, input, &command, &effect, &path_refs);
1116 let process_chain = if category == "shell" {
1117 command_process_chain(&command)
1118 } else {
1119 Vec::new()
1120 };
1121 ToolEvent {
1122 ts_ms,
1123 prompt_index,
1124 tool_name: name.to_string(),
1125 category,
1126 command,
1127 command_name,
1128 effect,
1129 process_chain,
1130 status: "observed".to_string(),
1131 path_groups,
1132 path_refs,
1133 edit_summary,
1134 domains,
1135 call_id,
1136 }
1137}
1138
1139fn command_from_tool_input(input: &Value) -> String {
1140 for key in ["cmd", "command", "pattern", "file_path", "path", "text"] {
1141 if let Some(value) = input.get(key).and_then(Value::as_str)
1142 && !value.is_empty()
1143 {
1144 return if key == "pattern" {
1145 format!("search {value}")
1146 } else {
1147 value.to_string()
1148 };
1149 }
1150 }
1151 if input.is_null() {
1152 String::new()
1153 } else {
1154 truncate_clean(&input.to_string(), 300)
1155 }
1156}
1157
1158fn parse_tool_args(value: &Value) -> Value {
1159 if let Some(text) = value.as_str() {
1160 serde_json::from_str(text).unwrap_or_else(|_| serde_json::json!({ "text": text }))
1161 } else {
1162 value.clone()
1163 }
1164}
1165
1166fn status_from_output(output: &str) -> &'static str {
1167 let lowered = output.to_ascii_lowercase();
1168 if lowered.contains("process exited with code 0") || lowered.contains("\"is_error\":false") {
1169 "ok"
1170 } else if lowered.contains("process exited with code")
1171 || lowered.contains("\"is_error\":true")
1172 || lowered.contains("error")
1173 {
1174 "fail"
1175 } else {
1176 "observed"
1177 }
1178}
1179
1180pub fn tool_category(name: &str, command: &str) -> String {
1181 let n = name.to_ascii_lowercase();
1182 if n.ends_with("exec_command") || n == "bash" {
1183 "shell"
1184 } else if ["apply_patch", "edit", "write", "multiedit", "notebookedit"].contains(&n.as_str()) {
1185 "edit"
1186 } else if ["read", "grep", "glob", "ls"].contains(&n.as_str()) {
1187 "read"
1188 } else if n.contains("web")
1189 || n.contains("browser")
1190 || n.contains("search")
1191 || command.contains("http")
1192 {
1193 "network"
1194 } else if n.contains("plan") || n.contains("todo") {
1195 "plan"
1196 } else if n.contains("task") || n.contains("agent") {
1197 "subagent"
1198 } else {
1199 "tool"
1200 }
1201 .to_string()
1202}
1203
1204fn command_effect(command: &str) -> String {
1205 let cmd = basename_from_command(command);
1206 let text = command.to_ascii_lowercase();
1207 if ["cargo", "pytest", "npm", "pnpm", "yarn", "go", "make"].contains(&cmd.as_str())
1208 && any_word(&text, &["test", "check", "build", "clippy"])
1209 {
1210 "test"
1211 } else if cmd == "git"
1212 && any_word(
1213 &text,
1214 &["commit", "push", "add", "checkout", "merge", "rebase"],
1215 )
1216 {
1217 "repo"
1218 } else if ["curl", "wget", "ssh", "scp", "git"].contains(&cmd.as_str())
1219 && (any_word(
1220 &text,
1221 &["clone", "fetch", "pull", "push", "curl", "wget", "ssh"],
1222 ) || text.contains("http://")
1223 || text.contains("https://"))
1224 {
1225 "network"
1226 } else if has_output_redirection(command)
1227 || ["tee", "cp", "mv", "rm", "mkdir", "touch"].contains(&cmd.as_str())
1228 || (cmd == "sed" && (text.contains(" -i") || text.contains("--in-place")))
1229 || (["python", "python3", "node", "npm"].contains(&cmd.as_str())
1230 && (text.contains('>')
1231 || text.contains("--write")
1232 || text.contains("write_text")
1233 || text.contains("writefile")))
1234 {
1235 "write"
1236 } else if [
1237 "rg", "grep", "sed", "cat", "head", "tail", "find", "ls", "nl", "wc", "jq", "git",
1238 ]
1239 .contains(&cmd.as_str())
1240 {
1241 "read"
1242 } else if text.contains("http://")
1243 || text.contains("https://")
1244 || text.contains("crates.io")
1245 || text.contains("github.com")
1246 {
1247 "network"
1248 } else {
1249 "process"
1250 }
1251 .to_string()
1252}
1253
1254fn has_output_redirection(command: &str) -> bool {
1255 shell_segments(command)
1256 .iter()
1257 .any(|parts| !output_redirection_targets(parts).is_empty())
1258}
1259
1260fn any_word(text: &str, words: &[&str]) -> bool {
1261 text.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
1262 .any(|part| words.contains(&part))
1263}
1264
1265fn basename_from_command(command: &str) -> String {
1266 let parts = split_shell(command);
1267 let mut idx = 0;
1268 while idx < parts.len()
1269 && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
1270 &Path::new(&parts[idx])
1271 .file_name()
1272 .and_then(|v| v.to_str())
1273 .unwrap_or(""),
1274 )
1275 {
1276 idx += 1;
1277 if idx < parts.len() && parts[idx].starts_with('-') {
1278 idx += 1;
1279 }
1280 }
1281 parts
1282 .get(idx)
1283 .and_then(|part| process_name_from_part(part))
1284 .unwrap_or_else(|| "none".to_string())
1285}
1286
1287pub fn command_process_chain(command: &str) -> Vec<String> {
1288 process_chain_from_parts(&split_shell(command))
1289}
1290
1291fn process_chain_from_parts(parts: &[String]) -> Vec<String> {
1292 if parts.is_empty() {
1293 return Vec::new();
1294 }
1295 let mut idx = 0;
1296 while idx < parts.len()
1297 && ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(
1298 &Path::new(&parts[idx])
1299 .file_name()
1300 .and_then(|v| v.to_str())
1301 .unwrap_or(""),
1302 )
1303 {
1304 idx += 1;
1305 if idx < parts.len() && parts[idx].starts_with('-') {
1306 idx += 1;
1307 }
1308 }
1309 let Some(proc_name) = parts.get(idx).and_then(|part| process_name_from_part(part)) else {
1310 return Vec::new();
1311 };
1312 let mut chain = vec![proc_name.clone()];
1313 if ["bash", "sh", "zsh"].contains(&proc_name.as_str()) {
1314 for flag_idx in idx + 1..parts.len().saturating_sub(1) {
1315 if ["-c", "-lc", "-cl"].contains(&parts[flag_idx].as_str()) {
1316 chain.extend(command_process_chain(&parts[flag_idx + 1]));
1317 break;
1318 }
1319 }
1320 }
1321 chain
1322}
1323
1324fn process_name_from_part(part: &str) -> Option<String> {
1325 let raw = part.trim_matches(['"', '\'']);
1326 if raw.is_empty() {
1327 return None;
1328 }
1329 let path = Path::new(raw);
1330 let file_name = path.file_name().and_then(|v| v.to_str()).unwrap_or(raw);
1331 let parts = path_component_strings(path);
1332 if looks_like_home_directory(&parts) && parts.len() <= 2 {
1333 return Some("external".to_string());
1334 }
1335 if contains_private_marker(file_name) {
1336 return Some("external".to_string());
1337 }
1338 Some(file_name.to_string())
1339}
1340
1341fn split_shell(command: &str) -> Vec<String> {
1342 let mut parts = Vec::new();
1343 let mut current = String::new();
1344 let mut quote = None;
1345 let mut escaped = false;
1346 for ch in command.chars() {
1347 if escaped {
1348 current.push(ch);
1349 escaped = false;
1350 } else if ch == '\\' {
1351 escaped = true;
1352 } else if quote == Some(ch) {
1353 quote = None;
1354 } else if quote.is_some() {
1355 current.push(ch);
1356 } else if ch == '\'' || ch == '"' {
1357 quote = Some(ch);
1358 } else if ch.is_whitespace() {
1359 if !current.is_empty() {
1360 parts.push(std::mem::take(&mut current));
1361 }
1362 } else {
1363 current.push(ch);
1364 }
1365 }
1366 if !current.is_empty() {
1367 parts.push(current);
1368 }
1369 parts
1370}
1371
1372fn shell_segments(command: &str) -> Vec<Vec<String>> {
1380 fn flush_word(tokens: &mut Vec<String>, current: &mut String) {
1381 if !current.is_empty() {
1382 tokens.push(std::mem::take(current));
1383 }
1384 }
1385
1386 fn flush_segment(segments: &mut Vec<Vec<String>>, tokens: &mut Vec<String>) {
1387 if !tokens.is_empty() {
1388 segments.push(std::mem::take(tokens));
1389 }
1390 }
1391
1392 let command = strip_heredoc_bodies(command);
1393 let mut segments = Vec::new();
1394 let mut tokens = Vec::new();
1395 let mut current = String::new();
1396 let mut quote = None;
1397 let mut escaped = false;
1398 let mut chars = command.chars().peekable();
1399 while let Some(ch) = chars.next() {
1400 if escaped {
1401 current.push(ch);
1402 escaped = false;
1403 continue;
1404 }
1405 if ch == '\\' {
1406 escaped = true;
1407 continue;
1408 }
1409 if quote == Some(ch) {
1410 quote = None;
1411 continue;
1412 }
1413 if quote.is_some() {
1414 current.push(ch);
1415 continue;
1416 }
1417 if ch == '\'' || ch == '"' {
1418 quote = Some(ch);
1419 continue;
1420 }
1421 if ch == '#' && current.is_empty() {
1422 for next in chars.by_ref() {
1423 if next == '\n' {
1424 flush_segment(&mut segments, &mut tokens);
1425 break;
1426 }
1427 }
1428 continue;
1429 }
1430 if ch.is_whitespace() {
1431 flush_word(&mut tokens, &mut current);
1432 if ch == '\n' {
1433 flush_segment(&mut segments, &mut tokens);
1434 }
1435 continue;
1436 }
1437 if ch == '&' && chars.peek() == Some(&'>') {
1438 flush_word(&mut tokens, &mut current);
1439 chars.next();
1440 let operator = if chars.peek() == Some(&'>') {
1441 chars.next();
1442 "&>>"
1443 } else {
1444 "&>"
1445 };
1446 tokens.push(operator.to_string());
1447 continue;
1448 }
1449 if matches!(ch, ';' | '|' | '(' | ')')
1450 || (ch == '&' && !is_redirection_token(tokens.last().map(String::as_str).unwrap_or("")))
1451 {
1452 flush_word(&mut tokens, &mut current);
1453 if (ch == '|' || ch == '&') && chars.peek() == Some(&ch) {
1454 chars.next();
1455 }
1456 flush_segment(&mut segments, &mut tokens);
1457 continue;
1458 }
1459 if ch == '>' || ch == '<' {
1460 flush_word(&mut tokens, &mut current);
1461 let mut operator = ch.to_string();
1462 while chars.peek() == Some(&ch) && operator.len() < 3 {
1463 operator.push(chars.next().expect("peeked redirection"));
1464 }
1465 tokens.push(operator);
1466 continue;
1467 }
1468 current.push(ch);
1469 }
1470 flush_word(&mut tokens, &mut current);
1471 flush_segment(&mut segments, &mut tokens);
1472 segments
1473}
1474
1475fn strip_heredoc_bodies(command: &str) -> String {
1479 fn delimiters(line: &str) -> Vec<String> {
1480 let bytes = line.as_bytes();
1481 let mut output = Vec::new();
1482 let mut index = 0;
1483 while index + 1 < bytes.len() {
1484 if bytes[index] != b'<' || bytes[index + 1] != b'<' {
1485 index += 1;
1486 continue;
1487 }
1488 index += 2;
1489 if bytes.get(index) == Some(&b'<') {
1490 index += 1;
1491 continue;
1492 }
1493 if bytes.get(index) == Some(&b'-') {
1494 index += 1;
1495 }
1496 while bytes.get(index).is_some_and(u8::is_ascii_whitespace) {
1497 index += 1;
1498 }
1499 let quote = bytes
1500 .get(index)
1501 .copied()
1502 .filter(|value| *value == b'\'' || *value == b'"');
1503 if quote.is_some() {
1504 index += 1;
1505 }
1506 let start = index;
1507 while let Some(value) = bytes.get(index) {
1508 if quote.is_some_and(|quote| *value == quote)
1509 || (quote.is_none()
1510 && (value.is_ascii_whitespace() || b";|&><".contains(value)))
1511 {
1512 break;
1513 }
1514 index += 1;
1515 }
1516 if start < index {
1517 output.push(line[start..index].to_string());
1518 }
1519 if quote.is_some() && bytes.get(index) == quote.as_ref() {
1520 index += 1;
1521 }
1522 }
1523 output
1524 }
1525
1526 let mut pending = VecDeque::<String>::new();
1527 let mut output = Vec::new();
1528 for line in command.lines() {
1529 if let Some(delimiter) = pending.front() {
1530 if line.trim_start_matches('\t').trim_end() == delimiter {
1531 pending.pop_front();
1532 }
1533 continue;
1534 }
1535 output.push(line);
1536 pending.extend(delimiters(line));
1537 }
1538 output.join("\n")
1539}
1540
1541fn is_redirection_token(token: &str) -> bool {
1542 [">", ">>", "&>", "&>>", "<", "<<", "<<<", "<>"].contains(&token)
1543}
1544
1545fn is_output_redirection_token(token: &str) -> bool {
1546 [">", ">>", "&>", "&>>"].contains(&token)
1547}
1548
1549fn output_redirection_targets(parts: &[String]) -> Vec<String> {
1550 let mut targets = Vec::new();
1551 let mut index = 0;
1552 while index < parts.len() {
1553 let token = parts[index].as_str();
1554 if is_output_redirection_token(token) {
1555 if let Some(target) = parts.get(index + 1)
1556 && !target.starts_with('&')
1557 && plausible_path_token(target)
1558 {
1559 targets.push(target.clone());
1560 }
1561 index += 2;
1562 continue;
1563 }
1564 index += 1;
1565 }
1566 targets
1567}
1568
1569fn input_redirection_targets(parts: &[String]) -> Vec<String> {
1570 let mut targets = Vec::new();
1571 let mut index = 0;
1572 while index < parts.len() {
1573 if ["<", "<>"].contains(&parts[index].as_str()) {
1574 if let Some(target) = parts.get(index + 1)
1575 && plausible_path_token(target)
1576 {
1577 targets.push(target.clone());
1578 }
1579 index += 2;
1580 continue;
1581 }
1582 index += 1;
1583 }
1584 targets
1585}
1586
1587fn extract_domains(text: &str) -> Vec<String> {
1588 let mut domains = BTreeSet::new();
1589 for part in text.split(|c: char| c.is_whitespace() || ['"', '\'', ')', '('].contains(&c)) {
1590 let stripped = part
1591 .strip_prefix("https://")
1592 .or_else(|| part.strip_prefix("http://"));
1593 if let Some(rest) = stripped
1594 && let Some(domain) = rest.split('/').next()
1595 && !domain.is_empty()
1596 {
1597 domains.insert(domain.to_ascii_lowercase());
1598 }
1599 for known in [
1600 "github.com",
1601 "crates.io",
1602 "huggingface.co",
1603 "hf.co",
1604 "openai.com",
1605 "anthropic.com",
1606 ] {
1607 if part.contains(known) {
1608 domains.insert(known.to_string());
1609 }
1610 }
1611 }
1612 domains.into_iter().collect()
1613}
1614
1615fn extract_path_groups(
1616 project_root: &Path,
1617 name: &str,
1618 input: &Value,
1619 command: &str,
1620) -> Vec<String> {
1621 let mut groups = BTreeSet::new();
1622 if ["write", "edit", "multiedit", "notebookedit", "read"]
1623 .contains(&name.to_ascii_lowercase().as_str())
1624 {
1625 for key in ["file_path", "path"] {
1626 if let Some(path) = input.get(key).and_then(Value::as_str) {
1627 groups.insert(path_group(path, project_root));
1628 }
1629 }
1630 }
1631 for (path, _) in shell_path_references_at(
1632 command,
1633 &command_effect(command),
1634 shell_working_directory(input),
1635 ) {
1636 groups.insert(path_group(&path, project_root));
1637 }
1638 groups.into_iter().filter(|v| v != "none").collect()
1639}
1640
1641fn extract_path_references(
1642 project_root: &Path,
1643 name: &str,
1644 input: &Value,
1645 command: &str,
1646 effect: &str,
1647) -> Vec<PathReference> {
1648 let lower_name = name.to_ascii_lowercase();
1649 let default_access = if effect == "write" {
1650 "write"
1651 } else if lower_name.contains("read")
1652 || lower_name.contains("search")
1653 || lower_name.contains("glob")
1654 || lower_name.contains("grep")
1655 {
1656 "read"
1657 } else {
1658 "reference"
1659 };
1660 let is_shell =
1661 lower_name.contains("bash") || lower_name.contains("exec") || lower_name.contains("shell");
1662 let mut refs = BTreeMap::<(String, String), String>::new();
1663 if !is_shell {
1667 collect_input_path_references(input, project_root, default_access, 0, &mut refs);
1668 }
1669
1670 for (path, access) in patch_path_references(command) {
1671 if let Some(path) = repository_relative_path(project_root, &path) {
1672 refs.insert((path, access.to_string()), "patch".to_string());
1673 }
1674 }
1675 if let Some(patch) = find_string_field(input, &["patch", "input"], 0) {
1676 for (path, access) in patch_path_references(patch) {
1677 if let Some(path) = repository_relative_path(project_root, &path) {
1678 refs.insert((path, access.to_string()), "patch".to_string());
1679 }
1680 }
1681 }
1682
1683 if is_shell {
1684 for (raw, access) in
1685 shell_path_references_at(command, effect, shell_working_directory(input))
1686 {
1687 if let Some(path) = repository_relative_path(project_root, &raw) {
1688 refs.entry((path, access))
1689 .or_insert_with(|| "command".to_string());
1690 }
1691 }
1692 }
1693
1694 refs.into_iter()
1695 .map(|((path, access), source)| PathReference {
1696 path,
1697 access,
1698 source,
1699 })
1700 .collect()
1701}
1702
1703fn shell_path_references_at(
1704 command: &str,
1705 effect: &str,
1706 working_directory: Option<&Path>,
1707) -> Vec<(String, String)> {
1708 shell_path_references_inner(
1709 command,
1710 effect,
1711 0,
1712 Some(working_directory.map_or_else(PathBuf::new, Path::to_path_buf)),
1713 )
1714}
1715
1716fn shell_path_references_inner(
1717 command: &str,
1718 effect: &str,
1719 depth: usize,
1720 mut working_directory: Option<PathBuf>,
1721) -> Vec<(String, String)> {
1722 if depth > 2 {
1723 return Vec::new();
1724 }
1725 let mut refs = Vec::new();
1726 for parts in shell_segments(command) {
1727 if update_shell_working_directory(&parts, &mut working_directory) {
1728 continue;
1729 }
1730 refs.extend(shell_segment_path_references(
1731 &parts,
1732 effect,
1733 depth,
1734 working_directory.as_deref(),
1735 ));
1736 }
1737 refs
1738}
1739
1740fn shell_segment_path_references(
1741 parts: &[String],
1742 effect: &str,
1743 depth: usize,
1744 working_directory: Option<&Path>,
1745) -> Vec<(String, String)> {
1746 let Some(command_index) = shell_command_index(parts) else {
1747 return Vec::new();
1748 };
1749 let command_name = process_name_from_part(&parts[command_index]).unwrap_or_default();
1750 let redirect_targets = output_redirection_targets(parts);
1751 let input_targets = input_redirection_targets(parts);
1752 let mut operands = Vec::new();
1753 let mut index = command_index + 1;
1754 while index < parts.len() {
1755 if is_redirection_token(&parts[index]) {
1756 index += 2;
1757 continue;
1758 }
1759 operands.push(parts[index].clone());
1760 index += 1;
1761 }
1762 let mut refs = redirect_targets
1763 .into_iter()
1764 .map(|path| (path, "write".to_string()))
1765 .collect::<Vec<_>>();
1766 refs.extend(
1767 input_targets
1768 .into_iter()
1769 .map(|path| (path, "read".to_string())),
1770 );
1771
1772 if ["bash", "sh", "zsh"].contains(&command_name.as_str()) {
1773 for flag_index in 0..operands.len().saturating_sub(1) {
1774 if ["-c", "-lc", "-cl"].contains(&operands[flag_index].as_str()) {
1775 refs.extend(shell_path_references_inner(
1776 &operands[flag_index + 1],
1777 effect,
1778 depth + 1,
1779 working_directory.map(Path::to_path_buf),
1780 ));
1781 break;
1782 }
1783 }
1784 return resolve_shell_paths(refs, working_directory);
1785 }
1786
1787 let path_operands = |values: &[String]| {
1788 values
1789 .iter()
1790 .filter(|part| !part.starts_with('-') && plausible_path_token(part))
1791 .cloned()
1792 .collect::<Vec<_>>()
1793 };
1794 match command_name.as_str() {
1795 "cp" => refs.extend(cp_path_references(&operands)),
1796 "mv" | "rm" | "mkdir" | "touch" | "tee" => refs.extend(
1797 path_operands(&operands)
1798 .into_iter()
1799 .map(|path| (path, "write".to_string())),
1800 ),
1801 "sed" => refs.extend(sed_path_references(
1802 &operands,
1803 operands.iter().any(|operand| {
1804 operand == "-i"
1805 || operand.starts_with("-i")
1806 || operand == "--in-place"
1807 || operand.starts_with("--in-place=")
1808 }),
1809 )),
1810 "cat" | "head" | "tail" | "ls" | "nl" | "wc" | "source" | "." => {
1811 refs.extend(
1812 path_operands(&operands)
1813 .into_iter()
1814 .map(|path| (path, "read".to_string())),
1815 );
1816 }
1817 "find" => refs.extend(find_path_operands(&operands)),
1818 "rg" | "grep" => refs.extend(search_path_operands(&operands)),
1819 "jq" => refs.extend(jq_path_operands(&operands)),
1820 _ => {}
1824 }
1825 resolve_shell_paths(refs, working_directory)
1826}
1827
1828fn cp_path_references(operands: &[String]) -> Vec<(String, String)> {
1829 let plausible_operand = |value: &str| {
1830 plausible_path_token(value)
1831 || (!value.is_empty()
1832 && !value.starts_with(['-', '$'])
1833 && value
1834 .chars()
1835 .all(|ch| ch.is_ascii_alphanumeric() || "._-/".contains(ch)))
1836 };
1837 let mut paths = Vec::new();
1838 let mut target_directory = None;
1839 let mut index = 0;
1840 while index < operands.len() {
1841 let operand = &operands[index];
1842 if ["-t", "--target-directory"].contains(&operand.as_str()) {
1843 if let Some(target) = operands.get(index + 1)
1844 && plausible_operand(target)
1845 {
1846 target_directory = Some(target.clone());
1847 }
1848 index += 2;
1849 continue;
1850 }
1851 if let Some(target) = operand.strip_prefix("--target-directory=") {
1852 if plausible_operand(target) {
1853 target_directory = Some(target.to_string());
1854 }
1855 index += 1;
1856 continue;
1857 }
1858 if ["-S", "--suffix"].contains(&operand.as_str()) {
1859 index += 2;
1860 continue;
1861 }
1862 if !operand.starts_with('-') && plausible_operand(operand) {
1863 paths.push(operand.clone());
1864 }
1865 index += 1;
1866 }
1867
1868 let mut refs = Vec::new();
1869 if let Some(target) = target_directory {
1870 let preserve_parents = operands.iter().any(|value| value == "--parents");
1871 for source in paths {
1872 refs.push((source.clone(), "read".to_string()));
1873 let relative_target = if preserve_parents {
1874 Some(source.trim_start_matches('/'))
1875 } else {
1876 Path::new(&source)
1877 .file_name()
1878 .and_then(|value| value.to_str())
1879 };
1880 if let Some(relative_target) = relative_target {
1881 refs.push((
1882 Path::new(&target)
1883 .join(relative_target)
1884 .to_string_lossy()
1885 .into_owned(),
1886 "write".to_string(),
1887 ));
1888 }
1889 }
1890 } else if let Some((target, sources)) = paths.split_last() {
1891 refs.extend(
1892 sources
1893 .iter()
1894 .cloned()
1895 .map(|path| (path, "read".to_string())),
1896 );
1897 refs.push((target.clone(), "write".to_string()));
1898 }
1899 refs
1900}
1901
1902fn resolve_shell_paths(
1903 refs: Vec<(String, String)>,
1904 working_directory: Option<&Path>,
1905) -> Vec<(String, String)> {
1906 refs.into_iter()
1907 .filter_map(|(path, access)| {
1908 shell_path_at_working_directory(&path, working_directory).map(|path| (path, access))
1909 })
1910 .collect()
1911}
1912
1913fn shell_working_directory(input: &Value) -> Option<&Path> {
1914 ["workdir", "cwd"]
1915 .iter()
1916 .find_map(|key| input.get(*key).and_then(Value::as_str))
1917 .map(Path::new)
1918}
1919
1920fn shell_path_at_working_directory(path: &str, working_directory: Option<&Path>) -> Option<String> {
1921 if path.starts_with('~') || path.starts_with('$') {
1922 return None;
1923 }
1924 let path = Path::new(path);
1925 if path.is_absolute() {
1926 return Some(path.to_string_lossy().into_owned());
1927 }
1928 let working_directory = working_directory?;
1929 Some(working_directory.join(path).to_string_lossy().into_owned())
1930}
1931
1932fn update_shell_working_directory(
1933 parts: &[String],
1934 working_directory: &mut Option<PathBuf>,
1935) -> bool {
1936 let Some(command_index) = shell_command_index(parts) else {
1937 return false;
1938 };
1939 if process_name_from_part(&parts[command_index]).as_deref() != Some("cd") {
1940 return false;
1941 }
1942 let target = parts[command_index + 1..]
1943 .iter()
1944 .find(|part| !part.starts_with('-'))
1945 .filter(|part| !part.starts_with('$') && !part.is_empty())
1946 .map(PathBuf::from);
1947 *working_directory = match (working_directory.take(), target) {
1948 (_, None) => None,
1949 (_, Some(target)) if target.is_absolute() => Some(target),
1950 (Some(base), Some(target)) => Some(base.join(target)),
1951 (None, Some(_)) => None,
1952 };
1953 true
1954}
1955
1956fn shell_command_index(parts: &[String]) -> Option<usize> {
1957 let mut index = 0;
1958 while index < parts.len() {
1959 let part = parts[index].as_str();
1960 if ["then", "do", "else"].contains(&part) || is_shell_assignment(part) {
1961 index += 1;
1962 continue;
1963 }
1964 if ["sudo", "env", "command", "time", "timeout", "nice", "nohup"].contains(&part) {
1965 index += 1;
1966 while index < parts.len()
1967 && (parts[index].starts_with('-') || is_shell_assignment(&parts[index]))
1968 {
1969 index += 1;
1970 }
1971 continue;
1972 }
1973 return Some(index);
1974 }
1975 None
1976}
1977
1978fn is_shell_assignment(value: &str) -> bool {
1979 value.split_once('=').is_some_and(|(name, _)| {
1980 !name.is_empty()
1981 && name
1982 .chars()
1983 .all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1984 })
1985}
1986
1987fn find_path_operands(operands: &[String]) -> Vec<(String, String)> {
1988 operands
1989 .iter()
1990 .take_while(|part| !part.starts_with('-') && !["!", "("].contains(&part.as_str()))
1991 .filter(|part| plausible_path_token(part))
1992 .cloned()
1993 .map(|path| (path, "read".to_string()))
1994 .collect()
1995}
1996
1997fn search_path_operands(operands: &[String]) -> Vec<(String, String)> {
1998 let mut refs = Vec::new();
1999 let mut pattern_seen = operands.iter().any(|part| part == "--files");
2000 let mut index = 0;
2001 while index < operands.len() {
2002 let part = operands[index].as_str();
2003 if part == "--" {
2004 refs.extend(
2005 operands[index + 1..]
2006 .iter()
2007 .filter(|path| plausible_path_token(path))
2008 .cloned()
2009 .map(|path| (path, "read".to_string())),
2010 );
2011 break;
2012 }
2013 let option_with_value = [
2014 "-e",
2015 "--regexp",
2016 "-f",
2017 "--file",
2018 "-g",
2019 "--glob",
2020 "-t",
2021 "--type",
2022 "-m",
2023 "--max-count",
2024 "-A",
2025 "-B",
2026 "-C",
2027 "--context",
2028 "--encoding",
2029 ];
2030 if option_with_value.contains(&part) {
2031 if ["-e", "--regexp"].contains(&part) {
2032 pattern_seen = true;
2033 } else if ["-f", "--file"].contains(&part)
2034 && let Some(path) = operands.get(index + 1)
2035 && plausible_path_token(path)
2036 {
2037 refs.push((path.clone(), "read".to_string()));
2038 }
2039 index += 2;
2040 continue;
2041 }
2042 if part.starts_with('-') {
2043 index += 1;
2044 continue;
2045 }
2046 if !pattern_seen {
2047 pattern_seen = true;
2048 } else if plausible_path_token(part) {
2049 refs.push((part.to_string(), "read".to_string()));
2050 }
2051 index += 1;
2052 }
2053 refs
2054}
2055
2056fn jq_path_operands(operands: &[String]) -> Vec<(String, String)> {
2057 let mut refs = Vec::new();
2058 let mut filter_seen = false;
2059 let mut index = 0;
2060 while index < operands.len() {
2061 let part = operands[index].as_str();
2062 if ["-f", "--from-file"].contains(&part) {
2063 filter_seen = true;
2064 if let Some(path) = operands.get(index + 1)
2065 && plausible_path_token(path)
2066 {
2067 refs.push((path.clone(), "read".to_string()));
2068 }
2069 index += 2;
2070 continue;
2071 }
2072 if part.starts_with('-') {
2073 index += 1;
2074 continue;
2075 }
2076 if !filter_seen {
2077 filter_seen = true;
2078 } else if plausible_path_token(part) {
2079 refs.push((part.to_string(), "read".to_string()));
2080 }
2081 index += 1;
2082 }
2083 refs
2084}
2085
2086fn sed_path_references(operands: &[String], in_place: bool) -> Vec<(String, String)> {
2087 let mut refs = Vec::new();
2088 let mut script_seen = false;
2089 let mut index = 0;
2090 while index < operands.len() {
2091 let part = &operands[index];
2092 if ["-e", "--expression"].contains(&part.as_str()) {
2093 script_seen = true;
2094 index += 2;
2095 continue;
2096 }
2097 if ["-f", "--file"].contains(&part.as_str()) {
2098 script_seen = true;
2099 if let Some(path) = operands.get(index + 1)
2100 && plausible_path_token(path)
2101 {
2102 refs.push((path.clone(), "read".to_string()));
2103 }
2104 index += 2;
2105 continue;
2106 }
2107 if let Some(path) = part.strip_prefix("--file=") {
2108 script_seen = true;
2109 if plausible_path_token(path) {
2110 refs.push((path.to_string(), "read".to_string()));
2111 }
2112 index += 1;
2113 continue;
2114 }
2115 if part.starts_with('-') {
2116 index += 1;
2117 continue;
2118 }
2119 if !script_seen {
2120 script_seen = true;
2121 } else if plausible_path_token(part) {
2122 refs.push((
2123 part.clone(),
2124 if in_place { "write" } else { "read" }.to_string(),
2125 ));
2126 }
2127 index += 1;
2128 }
2129 refs
2130}
2131
2132fn collect_input_path_references(
2133 value: &Value,
2134 project_root: &Path,
2135 access: &str,
2136 depth: usize,
2137 out: &mut BTreeMap<(String, String), String>,
2138) {
2139 if depth > 4 {
2140 return;
2141 }
2142 match value {
2143 Value::Object(map) => {
2144 for (key, value) in map {
2145 let lower = key.to_ascii_lowercase();
2146 if [
2147 "file_path",
2148 "filepath",
2149 "path",
2150 "notebook_path",
2151 "dir_path",
2152 "directory",
2153 ]
2154 .contains(&lower.as_str())
2155 && let Some(raw) = value.as_str()
2156 && let Some(path) = repository_relative_path(project_root, raw)
2157 {
2158 out.insert((path, access.to_string()), format!("input:{key}"));
2159 }
2160 if lower == "changes"
2161 && let Some(changes) = value.as_object()
2162 {
2163 for raw in changes.keys() {
2164 if let Some(path) = repository_relative_path(project_root, raw) {
2165 out.insert((path, "write".to_string()), "input:changes".to_string());
2166 }
2167 }
2168 }
2169 collect_input_path_references(value, project_root, access, depth + 1, out);
2170 }
2171 }
2172 Value::Array(values) => {
2173 for value in values {
2174 collect_input_path_references(value, project_root, access, depth + 1, out);
2175 }
2176 }
2177 _ => {}
2178 }
2179}
2180
2181fn repository_relative_path(project_root: &Path, raw: &str) -> Option<String> {
2182 let raw = raw.trim().trim_matches(['"', '\'']);
2183 if raw.is_empty() || raw.starts_with('~') || raw.starts_with('$') {
2189 return None;
2190 }
2191 let path = Path::new(raw);
2192 let repository_root = containing_git_root(project_root);
2193 let relative = if path.is_absolute() {
2194 if project_root.as_os_str().is_empty() {
2195 return None;
2196 }
2197 if let Some(repository_root) = repository_root.as_deref() {
2198 let target = lexical_normalize(path)?;
2199 if !target.starts_with(repository_root) {
2200 return None;
2201 }
2202 lexical_relative(project_root, &target)?
2203 } else {
2204 path.strip_prefix(project_root).ok()?.to_path_buf()
2205 }
2206 } else if path
2207 .components()
2208 .any(|component| component == std::path::Component::ParentDir)
2209 {
2210 let repository_root = repository_root?;
2211 let target = lexical_normalize(&project_root.join(path))?;
2212 if !target.starts_with(&repository_root) {
2213 return None;
2214 }
2215 lexical_relative(project_root, &target)?
2216 } else {
2217 path.to_path_buf()
2218 };
2219 let mut parts = Vec::new();
2220 for component in relative.components() {
2221 match component {
2222 std::path::Component::CurDir => {}
2223 std::path::Component::Normal(part) => {
2224 let part = part.to_str()?;
2225 if part.is_empty() || part == ".git" {
2226 return None;
2227 }
2228 parts.push(part);
2229 }
2230 std::path::Component::ParentDir => parts.push(".."),
2231 std::path::Component::RootDir | std::path::Component::Prefix(_) => return None,
2232 }
2233 }
2234 (!parts.is_empty()).then(|| parts.join("/"))
2235}
2236
2237fn containing_git_root(cwd: &Path) -> Option<PathBuf> {
2238 let mut cursor = cwd;
2239 loop {
2240 if cursor.join(".git").exists() {
2241 return Some(cursor.to_path_buf());
2242 }
2243 cursor = cursor.parent()?;
2244 }
2245}
2246
2247fn lexical_normalize(path: &Path) -> Option<PathBuf> {
2248 let mut output = PathBuf::new();
2249 for component in path.components() {
2250 match component {
2251 std::path::Component::Prefix(prefix) => output.push(prefix.as_os_str()),
2252 std::path::Component::RootDir => output.push(Path::new("/")),
2253 std::path::Component::CurDir => {}
2254 std::path::Component::Normal(part) => output.push(part),
2255 std::path::Component::ParentDir => {
2256 if !output.pop() {
2257 return None;
2258 }
2259 }
2260 }
2261 }
2262 Some(output)
2263}
2264
2265fn lexical_relative(base: &Path, target: &Path) -> Option<PathBuf> {
2266 let base = lexical_normalize(base)?;
2267 let target = lexical_normalize(target)?;
2268 let base_parts = base.components().collect::<Vec<_>>();
2269 let target_parts = target.components().collect::<Vec<_>>();
2270 let common = base_parts
2271 .iter()
2272 .zip(&target_parts)
2273 .take_while(|(left, right)| left == right)
2274 .count();
2275 if common == 0 {
2276 return None;
2277 }
2278 let mut relative = PathBuf::new();
2279 for _ in common..base_parts.len() {
2280 relative.push("..");
2281 }
2282 for component in &target_parts[common..] {
2283 relative.push(component.as_os_str());
2284 }
2285 Some(relative)
2286}
2287
2288fn patch_path_references(text: &str) -> Vec<(String, &'static str)> {
2289 let mut refs = Vec::new();
2290 for line in text.lines() {
2291 for (prefix, access) in [
2292 ("*** Add File: ", "write"),
2293 ("*** Update File: ", "write"),
2294 ("*** Delete File: ", "write"),
2295 ("*** Move to: ", "write"),
2296 ] {
2297 if let Some(path) = line.trim().strip_prefix(prefix) {
2298 refs.push((path.trim().to_string(), access));
2299 }
2300 }
2301 }
2302 refs
2303}
2304
2305fn edit_summary_from_input(
2306 name: &str,
2307 input: &Value,
2308 command: &str,
2309 effect: &str,
2310 path_refs: &[PathReference],
2311) -> Option<EditSummary> {
2312 if effect != "write" {
2313 return None;
2314 }
2315 let write_paths = path_refs
2316 .iter()
2317 .filter(|reference| reference.access == "write")
2318 .map(|reference| reference.path.as_str())
2319 .collect::<BTreeSet<_>>();
2320 if write_paths.len() != 1 {
2324 return None;
2325 }
2326 let before = find_string_field(input, &["old_string", "oldText", "before"], 0);
2327 let after = find_string_field(input, &["new_string", "newText", "content", "after"], 0);
2328 if before.is_some() || after.is_some() {
2329 return Some(EditSummary {
2330 before_bytes: before.map_or(0, |value| value.len() as u64),
2331 after_bytes: after.map_or(0, |value| value.len() as u64),
2332 removed_lines: before.map_or(0, line_count),
2333 added_lines: after.map_or(0, line_count),
2334 before_hash: before.map(content_fingerprint),
2335 after_hash: after.map(content_fingerprint),
2336 payload_kind: if before.is_some() {
2337 "replace".to_string()
2338 } else {
2339 "write".to_string()
2340 },
2341 });
2342 }
2343
2344 let patch = find_string_field(input, &["patch", "input", "unified_diff"], 0)
2345 .filter(|value| value.contains("*** Begin Patch") || value.contains("@@"))
2346 .or_else(|| {
2347 (command.contains("*** Begin Patch") || command.contains("@@")).then_some(command)
2348 });
2349 if let Some(patch) = patch {
2350 let hunk_markers = patch.lines().filter(|line| line.starts_with("@@")).count();
2351 let file_markers = patch
2352 .lines()
2353 .filter(|line| {
2354 [
2355 "*** Add File: ",
2356 "*** Update File: ",
2357 "*** Delete File: ",
2358 "*** Move to: ",
2359 ]
2360 .iter()
2361 .any(|prefix| line.trim().starts_with(prefix))
2362 })
2363 .count();
2364 if hunk_markers > 1 || file_markers > 1 {
2365 return None;
2366 }
2367 let added = patch
2368 .lines()
2369 .filter(|line| line.starts_with('+') && !line.starts_with("+++"))
2370 .map(|line| &line[1..])
2371 .collect::<Vec<_>>();
2372 let removed = patch
2373 .lines()
2374 .filter(|line| line.starts_with('-') && !line.starts_with("---"))
2375 .map(|line| &line[1..])
2376 .collect::<Vec<_>>();
2377 let added_text = added.join("\n");
2378 let removed_text = removed.join("\n");
2379 return Some(EditSummary {
2380 before_bytes: removed_text.len() as u64,
2381 after_bytes: added_text.len() as u64,
2382 added_lines: added.len() as u64,
2383 removed_lines: removed.len() as u64,
2384 before_hash: (!removed.is_empty()).then(|| content_fingerprint(&removed_text)),
2385 after_hash: (!added.is_empty()).then(|| content_fingerprint(&added_text)),
2386 payload_kind: "patch".to_string(),
2387 });
2388 }
2389
2390 Some(EditSummary {
2391 payload_kind: one_word(name, "write"),
2392 ..EditSummary::default()
2393 })
2394}
2395
2396fn find_string_field<'a>(value: &'a Value, keys: &[&str], depth: usize) -> Option<&'a str> {
2397 if depth > 4 {
2398 return None;
2399 }
2400 match value {
2401 Value::Object(map) => {
2402 for key in keys {
2403 if let Some(value) = map.get(*key).and_then(Value::as_str) {
2404 return Some(value);
2405 }
2406 }
2407 map.values()
2408 .find_map(|value| find_string_field(value, keys, depth + 1))
2409 }
2410 Value::Array(values) => values
2411 .iter()
2412 .find_map(|value| find_string_field(value, keys, depth + 1)),
2413 _ => None,
2414 }
2415}
2416
2417fn line_count(value: &str) -> u64 {
2418 if value.is_empty() {
2419 0
2420 } else {
2421 value.lines().count() as u64
2422 }
2423}
2424
2425fn content_fingerprint(value: &str) -> String {
2426 let normalized = value.replace("\r\n", "\n");
2427 short_hash(normalized.trim_end_matches('\n'), 24)
2428}
2429
2430fn plausible_path_token(part: &str) -> bool {
2431 let part = part.trim_matches(['"', '\'']);
2432 let lower = part.to_ascii_lowercase();
2433 let components = part.split('/').collect::<Vec<_>>();
2434 let looks_like_slash_separated_phrase = components.len() >= 3
2435 && components.iter().all(|component| {
2436 component.chars().all(char::is_alphabetic)
2437 && component.chars().next().is_some_and(char::is_uppercase)
2438 });
2439 if part.is_empty()
2440 || part.starts_with('-')
2441 || part.starts_with('$')
2442 || part.starts_with("http://")
2443 || part.starts_with("https://")
2444 || lower.starts_with("origin/")
2445 || lower.starts_with("refs/")
2446 || lower.starts_with("repos/")
2447 || part == "HEAD"
2448 || part.starts_with("HEAD.")
2449 || part.contains("...")
2450 || looks_like_slash_separated_phrase
2451 || part.len() > 140
2452 || part.chars().any(char::is_whitespace)
2453 || part.chars().any(|c| "{}()=;<>|`*?[]\"#$,:@^!".contains(c))
2454 {
2455 return false;
2456 }
2457 let suffix = Path::new(part)
2458 .extension()
2459 .and_then(|v| v.to_str())
2460 .unwrap_or("");
2461 part.contains('/')
2462 || [
2463 "rs", "py", "md", "json", "ts", "tsx", "toml", "lock", "js", "c", "h", "svg", "html",
2464 "css",
2465 ]
2466 .contains(&suffix)
2467}
2468
2469pub fn path_group(path: &str, project_root: &Path) -> String {
2470 let path = path.trim_matches(['"', '\'']);
2471 if path.is_empty() {
2472 return "none".to_string();
2473 }
2474 let p = Path::new(path);
2475 let parts = if p.is_absolute() {
2476 if let Ok(rel) = p.strip_prefix(project_root) {
2477 path_component_strings(rel)
2478 } else {
2479 return external_path_group(path, &path_component_strings(p));
2480 }
2481 } else {
2482 let parts = path_component_strings(p);
2483 if let Some(group) = sensitive_relative_path_group(path, &parts) {
2484 return group;
2485 }
2486 parts
2487 };
2488 collapse_project_path(parts)
2489}
2490
2491pub fn path_component_strings(path: &Path) -> Vec<String> {
2492 path.components()
2493 .filter_map(|c| {
2494 let part = c.as_os_str().to_string_lossy();
2495 let part = part.as_ref();
2496 if part == "." || part == "/" || part.is_empty() {
2497 None
2498 } else {
2499 Some(part.to_string())
2500 }
2501 })
2502 .collect()
2503}
2504
2505pub fn collapse_project_path(parts: Vec<String>) -> String {
2506 let parts = parts
2507 .into_iter()
2508 .filter(|part| part != "." && !part.is_empty())
2509 .map(|part| truncate_path_component(&part))
2510 .collect::<Vec<_>>();
2511 if parts.is_empty() {
2512 "repo".to_string()
2513 } else if [
2514 "collector",
2515 "frontend",
2516 "docs",
2517 "bpf",
2518 "agentpprof",
2519 "agent-session",
2520 ]
2521 .contains(&parts[0].as_str())
2522 {
2523 parts.into_iter().take(3).collect::<Vec<_>>().join("/")
2524 } else {
2525 parts.into_iter().take(2).collect::<Vec<_>>().join("/")
2526 }
2527}
2528
2529fn truncate_path_component(part: &str) -> String {
2530 if part.chars().count() > 48 {
2531 format!("{}...", part.chars().take(45).collect::<String>())
2532 } else {
2533 part.to_string()
2534 }
2535}
2536
2537fn external_path_group(raw: &str, parts: &[String]) -> String {
2538 sensitive_relative_path_group(raw, parts).unwrap_or_else(|| "external/path".to_string())
2539}
2540
2541fn sensitive_relative_path_group(raw: &str, parts: &[String]) -> Option<String> {
2542 let lowered = raw.to_ascii_lowercase();
2543 let lower_parts = parts
2544 .iter()
2545 .map(|part| part.to_ascii_lowercase())
2546 .collect::<Vec<_>>();
2547 if lower_parts.iter().any(|part| part == ".codex") {
2548 Some("external/codex".to_string())
2549 } else if lower_parts.iter().any(|part| part == ".claude") {
2550 Some("external/claude".to_string())
2551 } else if lower_parts.first().is_some_and(|part| part == "tmp")
2552 || lowered.contains("/tmp")
2553 || lowered.contains("_/tmp")
2554 || lower_parts
2555 .windows(2)
2556 .any(|window| window[0] == "var" && window[1] == "tmp")
2557 {
2558 Some("external/tmp".to_string())
2559 } else if lowered.starts_with("~/")
2560 || lowered == "~"
2561 || lowered.contains("/home")
2562 || lowered.contains("_/home")
2563 || lowered.contains("-home-")
2564 || lowered.contains("/users")
2565 || lowered.contains("_/users")
2566 || looks_like_home_directory(&lower_parts)
2567 || contains_private_marker(&lowered)
2568 {
2569 Some("external/home".to_string())
2570 } else {
2571 None
2572 }
2573}
2574
2575pub fn looks_like_home_directory(parts: &[String]) -> bool {
2576 parts
2577 .first()
2578 .is_some_and(|part| part == "home" || part == "users")
2579}
2580
2581fn current_username() -> Option<String> {
2582 dirs::home_dir()
2583 .and_then(|home| {
2584 home.file_name()
2585 .map(|part| part.to_string_lossy().to_string())
2586 })
2587 .filter(|name| !name.is_empty())
2588}
2589
2590pub fn contains_private_marker(text: &str) -> bool {
2591 let lowered = text.to_ascii_lowercase();
2592 current_username()
2593 .map(|name| lowered.contains(&name.to_ascii_lowercase()))
2594 .unwrap_or(false)
2595}
2596
2597fn content_to_text(value: &Value) -> String {
2598 match value {
2599 Value::String(s) => s.clone(),
2600 Value::Array(items) => items
2601 .iter()
2602 .filter_map(|item| {
2603 if let Some(text) = item.as_str() {
2604 return Some(text.to_string());
2605 }
2606 let typ = item.get("type").and_then(Value::as_str).unwrap_or("");
2607 if typ == "tool_result" || typ == "tool_use" || typ == "function_call" {
2608 return None;
2609 }
2610 if typ == "thinking" {
2612 return item
2613 .get("thinking")
2614 .and_then(Value::as_str)
2615 .filter(|s| !s.is_empty())
2616 .map(str::to_string);
2617 }
2618 item.get("text")
2619 .or_else(|| item.get("content"))
2620 .and_then(Value::as_str)
2621 .map(str::to_string)
2622 })
2623 .collect::<Vec<_>>()
2624 .join("\n"),
2625 Value::Object(_) => value
2626 .get("text")
2627 .or_else(|| value.get("content"))
2628 .and_then(Value::as_str)
2629 .unwrap_or("")
2630 .to_string(),
2631 _ => String::new(),
2632 }
2633}
2634
2635fn claude_is_tool_result(content: &Value) -> bool {
2636 content.as_array().is_some_and(|items| {
2637 !items.is_empty()
2638 && items
2639 .iter()
2640 .all(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
2641 })
2642}
2643
2644fn claude_tool_result_ids(content: &Value) -> Vec<String> {
2645 content
2646 .as_array()
2647 .into_iter()
2648 .flatten()
2649 .filter_map(|item| {
2650 item.get("tool_use_id")
2651 .and_then(Value::as_str)
2652 .map(str::to_string)
2653 })
2654 .collect()
2655}
2656
2657fn local_session_ids(obj: &Value) -> (Option<String>, Option<String>) {
2658 let session_id = first_json_string(
2659 obj,
2660 &["sessionId", "session_id"],
2661 &["/payload/session_id", "/payload/sessionId"],
2662 );
2663 let conversation_id = first_json_string(
2664 obj,
2665 &["conversation_id", "conversationId", "thread_id", "threadId"],
2666 &[
2667 "/payload/conversation_id",
2668 "/payload/conversationId",
2669 "/payload/thread_id",
2670 "/payload/threadId",
2671 ],
2672 )
2673 .or_else(|| session_id.clone());
2674 (
2675 session_id.or_else(|| conversation_id.clone()),
2676 conversation_id,
2677 )
2678}
2679
2680fn first_json_string(obj: &Value, keys: &[&str], pointers: &[&str]) -> Option<String> {
2681 keys.iter()
2682 .filter_map(|key| obj.get(*key).and_then(Value::as_str))
2683 .chain(
2684 pointers
2685 .iter()
2686 .filter_map(|pointer| obj.pointer(pointer).and_then(Value::as_str)),
2687 )
2688 .find(|value| !value.is_empty())
2689 .map(str::to_string)
2690}
2691
2692fn codex_exec_option_arity(arg: &str) -> Option<usize> {
2693 if arg.contains('=') && arg.starts_with("--") {
2694 return Some(1);
2695 }
2696
2697 match arg {
2698 "--json"
2699 | "--skip-git-repo-check"
2700 | "--ephemeral"
2701 | "--ignore-user-config"
2702 | "--full-auto"
2703 | "--dangerously-bypass-approvals-and-sandbox" => Some(1),
2704 "-C" | "-a" | "-s" | "-m" | "-c" | "-p" | "--cd" | "--model" | "--sandbox"
2705 | "--profile" | "--config" | "--ask-for-approval" | "--approval-policy"
2706 | "--output-format" | "--color" => Some(2),
2707 _ => None,
2708 }
2709}
2710
2711fn shell_words(input: &str) -> Option<Vec<String>> {
2712 let mut words = Vec::new();
2713 let mut current = String::new();
2714 let mut quote = None::<char>;
2715 let mut chars = input.chars().peekable();
2716
2717 while let Some(ch) = chars.next() {
2718 match (quote, ch) {
2719 (None, c) if c.is_whitespace() => {
2720 if !current.is_empty() {
2721 words.push(std::mem::take(&mut current));
2722 }
2723 }
2724 (None, '\'' | '"') => quote = Some(ch),
2725 (Some(q), c) if c == q => quote = None,
2726 (_, '\\') => {
2727 if let Some(next) = chars.next() {
2728 current.push(next);
2729 }
2730 }
2731 _ => current.push(ch),
2732 }
2733 }
2734 if quote.is_some() {
2735 return None;
2736 }
2737 if !current.is_empty() {
2738 words.push(current);
2739 }
2740 Some(words)
2741}
2742
2743fn claude_usage_key(obj: &Value) -> String {
2744 obj.get("requestId")
2745 .or_else(|| obj.pointer("/message/id"))
2746 .or_else(|| obj.get("uuid"))
2747 .and_then(Value::as_str)
2748 .unwrap_or("usage")
2749 .to_string()
2750}
2751
2752fn local_message_preview(value: &Value) -> Option<String> {
2753 let mut parts = Vec::new();
2754 collect_local_text(value, &mut parts);
2755 clean_prompt_text(&parts.join(" "))
2756}
2757
2758fn collect_local_text(value: &Value, out: &mut Vec<String>) {
2759 match value {
2760 Value::String(text) => out.push(text.clone()),
2761 Value::Array(items) => {
2762 for item in items {
2763 collect_local_text(item, out);
2764 }
2765 }
2766 Value::Object(obj) => {
2767 if obj.get("type").and_then(Value::as_str).is_some_and(|typ| {
2768 typ == "tool_use" || typ == "function_call" || typ == "tool_result"
2769 }) {
2770 return;
2771 }
2772 for key in ["text", "content", "message", "input", "prompt"] {
2773 if let Some(value) = obj.get(key) {
2774 collect_local_text(value, out);
2775 }
2776 }
2777 }
2778 _ => {}
2779 }
2780}
2781
2782fn is_claude_tool_result(obj: &Value) -> bool {
2783 obj.get("toolUseResult").is_some()
2784 || obj.get("tool_use_result").is_some()
2785 || obj
2786 .pointer("/message/content")
2787 .and_then(Value::as_array)
2788 .is_some_and(|items| {
2789 items
2790 .iter()
2791 .any(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
2792 })
2793}
2794
2795fn find_file_arg(value: &Value) -> Option<&str> {
2796 match value {
2797 Value::Object(obj) => {
2798 for key in ["file_path", "path", "filepath"] {
2799 if let Some(path) = obj.get(key).and_then(Value::as_str) {
2800 return Some(path);
2801 }
2802 }
2803 obj.values().find_map(find_file_arg)
2804 }
2805 Value::Array(items) => items.iter().find_map(find_file_arg),
2806 _ => None,
2807 }
2808}
2809
2810fn is_noise_path(path: &str) -> bool {
2811 const NOISE: &[&str] = &[
2812 "/.claude/",
2813 "/.codex/",
2814 "/.gemini/",
2815 "/.git/",
2816 "/node_modules/",
2817 "/.npm/",
2818 "/.cache/",
2819 "CLAUDE.md",
2820 "AGENTS.md",
2821 ];
2822 NOISE.iter().any(|pat| path.contains(pat))
2823}
2824
2825fn clean_prompt_text(text: &str) -> Option<String> {
2826 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
2827 let text = text
2828 .strip_prefix("<session>")
2829 .and_then(|text| text.strip_suffix("</session>"))
2830 .unwrap_or(&text)
2831 .trim();
2832 (!text.is_empty()).then(|| text.to_string())
2833}
2834
2835pub fn short_hash(text: &str, n: usize) -> String {
2836 let digest = Sha256::digest(text.as_bytes());
2837 hex::encode(digest).chars().take(n).collect()
2838}
2839
2840pub fn truncate_clean(text: &str, limit: usize) -> String {
2841 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
2842 if text.chars().count() <= limit {
2843 return text;
2844 }
2845 text.chars()
2846 .take(limit.saturating_sub(1))
2847 .collect::<String>()
2848 + "."
2849}
2850
2851pub fn one_word(text: &str, default: &str) -> String {
2852 let mut cur = String::new();
2853 for ch in text.to_ascii_lowercase().chars() {
2854 if ch.is_ascii_alphanumeric() {
2855 cur.push(ch);
2856 } else if cur.len() >= 2 {
2857 break;
2858 } else {
2859 cur.clear();
2860 }
2861 }
2862 if cur.len() >= 2 {
2863 cur
2864 } else {
2865 default.to_string()
2866 }
2867}
2868
2869fn short_session_id(id: &str) -> String {
2870 let id = id.trim();
2871 if id.is_empty() {
2872 return "session".to_string();
2873 }
2874 let compact = id
2875 .rsplit(['/', '\\'])
2876 .next()
2877 .unwrap_or(id)
2878 .trim_end_matches(".jsonl");
2879 const MAX_SESSION_ID_CHARS: usize = 12;
2880 if compact.chars().count() <= MAX_SESSION_ID_CHARS {
2881 return compact.to_string();
2882 }
2883 let head = compact.chars().take(6).collect::<String>();
2884 let tail = compact
2885 .chars()
2886 .rev()
2887 .take(5)
2888 .collect::<Vec<_>>()
2889 .into_iter()
2890 .rev()
2891 .collect::<String>();
2892 format!("{head}.{tail}")
2893}
2894
2895fn json_i64(value: &Value, key: &str) -> i64 {
2896 value.get(key).and_then(Value::as_i64).unwrap_or(0)
2897}
2898
2899fn json_u64(value: &Value, key: &str) -> u64 {
2900 value.get(key).and_then(Value::as_u64).unwrap_or(0)
2901}
2902
2903fn codex_uncached_input(input: i64, cache: i64) -> i64 {
2904 input.saturating_sub(cache)
2905}
2906
2907fn ts_ms_from_event(value: &Value) -> Option<i64> {
2908 value
2909 .get("timestamp")
2910 .and_then(Value::as_str)
2911 .and_then(parse_ts_ms)
2912}
2913
2914fn parse_ts_ms(value: &str) -> Option<i64> {
2915 chrono::DateTime::parse_from_rfc3339(value)
2916 .ok()
2917 .map(|ts| ts.timestamp_millis())
2918}
2919
2920fn iso_ms(value: &str) -> Option<u64> {
2921 chrono::DateTime::parse_from_rfc3339(value)
2922 .ok()
2923 .and_then(|ts| u64::try_from(ts.timestamp_millis()).ok())
2924}
2925
2926fn system_time_ms(value: SystemTime) -> u64 {
2927 value
2928 .duration_since(UNIX_EPOCH)
2929 .unwrap_or_default()
2930 .as_millis() as u64
2931}
2932
2933#[cfg(test)]
2934mod tests {
2935 use super::*;
2936 use serde_json::json;
2937 use std::time::UNIX_EPOCH;
2938
2939 #[test]
2940 fn local_session_ids_keep_distinct_conversation_id() {
2941 assert_eq!(
2942 local_session_ids(&json!({"sessionId": "run", "conversation_id": "conv"})),
2943 (Some("run".to_string()), Some("conv".to_string()))
2944 );
2945 assert_eq!(
2946 local_session_ids(&json!({"payload": {"thread_id": "thread"}})),
2947 (Some("thread".to_string()), Some("thread".to_string()))
2948 );
2949 assert_eq!(
2950 local_session_ids(&json!({"payload": {"model": "gpt"}})),
2951 (None, None)
2952 );
2953 }
2954
2955 #[test]
2956 fn agent_jsonl_events_share_one_ir() {
2957 let codex = concat!(
2958 r#"{"type":"turn_context","payload":{"model":"gpt-5","cwd":"/repo"}}"#,
2959 "\n",
2960 r#"{"type":"event_msg","payload":{"type":"user_message","message":"run tests"}}"#,
2961 "\n",
2962 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"c1","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
2963 "\n",
2964 r#"{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}}"#,
2965 );
2966 let claude = concat!(
2967 r#"{"type":"user","message":{"content":"check build"}}"#,
2968 "\n",
2969 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}}}"#,
2970 );
2971
2972 for (agent, content, tool, model, tokens) in [
2973 (AGENT_CODEX, codex, "exec_command", "gpt-5", 15),
2974 (AGENT_CLAUDE, claude, "Bash", "claude-opus", 12),
2975 ] {
2976 let session = parse_session_content(
2977 agent,
2978 &PathBuf::from("/tmp/session.jsonl"),
2979 UNIX_EPOCH,
2980 content,
2981 )
2982 .expect("session");
2983 assert_eq!(session.events.tools[0].tool_name, tool);
2984 assert_eq!(session.events.tools[0].category, "shell");
2985 assert_eq!(session.events.llm_responses[0].model, model);
2986 let usage = &session.events.llm_responses[0];
2987 let total = usage
2988 .total_tokens
2989 .max(usage.input_tokens + usage.output_tokens + usage.cache_tokens);
2990 assert_eq!(total, tokens);
2991 }
2992 }
2993
2994 #[test]
2995 fn codex_token_count_uses_uncached_cumulative_totals() {
2996 let content = concat!(
2997 r#"{"type":"turn_context","payload":{"model":"gpt-5.6-sol","cwd":"/repo"}}"#,
2998 "\n",
2999 r#"{"type":"event_msg","payload":{"type":"user_message","message":"run tests"}}"#,
3000 "\n",
3001 r#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":2109758505,"output_tokens":5136738,"cached_input_tokens":2069213696,"total_tokens":2114895243}}}}"#,
3002 );
3003
3004 let session = parse_session_content(
3005 AGENT_CODEX,
3006 &PathBuf::from("/tmp/session.jsonl"),
3007 UNIX_EPOCH,
3008 content,
3009 )
3010 .expect("session");
3011
3012 assert_eq!(session.usage.input_tokens, 40_544_809);
3013 assert_eq!(session.usage.output_tokens, 5_136_738);
3014 assert_eq!(session.usage.cache_read_tokens, 2_069_213_696);
3015 assert_eq!(session.usage.total_tokens, 45_681_547);
3016
3017 let response = &session.events.llm_responses[0];
3018 assert_eq!(response.input_tokens, 40_544_809);
3019 assert_eq!(response.output_tokens, 5_136_738);
3020 assert_eq!(response.cache_tokens, 2_069_213_696);
3021 assert_eq!(response.total_tokens, 45_681_547);
3022 assert_eq!(codex_uncached_input(99_000_000, 88_000_000), 11_000_000);
3023 }
3024
3025 #[test]
3026 fn codex_exec_prompt_handles_latest_cli_options() {
3027 let command = concat!(
3028 "/tmp/tools/bin/codex exec --skip-git-repo-check --ignore-user-config ",
3029 "-c model_provider=\"agentsight-mock\" ",
3030 "-c model_providers.agentsight-mock.name=\"AgentSight Mock\" ",
3031 "--sandbox read-only --model gpt-agentsight-mock ",
3032 "agentsight mock prompt collect this exact text"
3033 );
3034
3035 assert_eq!(
3036 codex_exec_prompt(command).as_deref(),
3037 Some("agentsight mock prompt collect this exact text")
3038 );
3039 }
3040
3041 #[test]
3042 fn common_shell_mutators_are_classified_as_writes() {
3043 for command in [
3044 "rm src/old.rs",
3045 "mv src/old.rs src/new.rs",
3046 "cp src/a.rs src/b.rs",
3047 "sed -i 's/a/b/' src/lib.rs",
3048 "sed --in-place=.bak 's/a/b/' src/lib.rs",
3049 "touch src/new.rs",
3050 ] {
3051 assert_eq!(command_effect(command), "write", "{command}");
3052 }
3053 assert_eq!(command_effect("sed -n '1,5p' src/lib.rs"), "read");
3054 }
3055
3056 #[test]
3057 fn shell_operands_preserve_read_write_roles_and_redirections() {
3058 let cp = tool_event_from_input(
3059 Some("/repo"),
3060 Some(42),
3061 0,
3062 "Bash",
3063 &json!({"command": "cp src/a.rs src/b.rs"}),
3064 None,
3065 );
3066 assert!(
3067 cp.path_refs
3068 .iter()
3069 .any(|reference| { reference.path == "src/a.rs" && reference.access == "read" })
3070 );
3071 assert!(
3072 cp.path_refs
3073 .iter()
3074 .any(|reference| { reference.path == "src/b.rs" && reference.access == "write" })
3075 );
3076
3077 let sed = tool_event_from_input(
3078 Some("/repo"),
3079 Some(42),
3080 0,
3081 "Bash",
3082 &json!({"command": "sed -i 's/a/b/' src/lib.rs"}),
3083 None,
3084 );
3085 assert_eq!(sed.path_refs.len(), 1);
3086 assert_eq!(sed.path_refs[0].path, "src/lib.rs");
3087 assert_eq!(sed.path_refs[0].access, "write");
3088
3089 let input_redirect = tool_event_from_input(
3090 Some("/repo"),
3091 Some(42),
3092 0,
3093 "Bash",
3094 &json!({"command": "cat < docs/input.md > docs/output.md"}),
3095 None,
3096 );
3097 assert!(
3098 input_redirect.path_refs.iter().any(|reference| {
3099 reference.path == "docs/input.md" && reference.access == "read"
3100 })
3101 );
3102 assert!(input_redirect.path_refs.iter().any(|reference| {
3103 reference.path == "docs/output.md" && reference.access == "write"
3104 }));
3105
3106 for command in [
3107 "cp -t generated src/a.rs src/b.rs",
3108 "cp --target-directory=generated src/a.rs src/b.rs",
3109 ] {
3110 let event = tool_event_from_input(
3111 Some("/repo"),
3112 Some(42),
3113 0,
3114 "Bash",
3115 &json!({"command": command}),
3116 None,
3117 );
3118 assert!(
3119 event.path_refs.iter().any(|reference| {
3120 reference.path == "src/a.rs" && reference.access == "read"
3121 })
3122 );
3123 assert!(event.path_refs.iter().any(|reference| {
3124 reference.path == "generated/a.rs" && reference.access == "write"
3125 }));
3126 assert!(event.path_refs.iter().any(|reference| {
3127 reference.path == "generated/b.rs" && reference.access == "write"
3128 }));
3129 assert!(
3130 !event.path_refs.iter().any(|reference| {
3131 reference.path == "generated" && reference.access == "write"
3132 })
3133 );
3134 }
3135
3136 let parents = tool_event_from_input(
3137 Some("/repo"),
3138 Some(42),
3139 0,
3140 "Bash",
3141 &json!({"command": "cp --parents -t generated src/a.rs"}),
3142 None,
3143 );
3144 assert!(parents.path_refs.iter().any(|reference| {
3145 reference.path == "generated/src/a.rs" && reference.access == "write"
3146 }));
3147
3148 let heredoc = tool_event_from_input(
3149 Some("/repo"),
3150 Some(42),
3151 0,
3152 "Bash",
3153 &json!({"command": "cat <<'EOF' > src/generated.rs\ncat private/fiction.rs\nEOF\ncat src/real.rs"}),
3154 None,
3155 );
3156 assert!(heredoc.path_refs.iter().any(|reference| {
3157 reference.path == "src/generated.rs" && reference.access == "write"
3158 }));
3159 assert!(
3160 heredoc
3161 .path_refs
3162 .iter()
3163 .any(|reference| { reference.path == "src/real.rs" && reference.access == "read" })
3164 );
3165 assert!(
3166 !heredoc
3167 .path_refs
3168 .iter()
3169 .any(|reference| reference.path == "private/fiction.rs")
3170 );
3171
3172 for command in [
3173 "cat src/template.rs > src/generated.rs",
3174 "echo generated >> docs/output.md",
3175 "echo generated>docs/output.md",
3176 "printf generated >docs/output.md",
3177 "cat <<'EOF' > src/heredoc.rs",
3178 ] {
3179 let event = tool_event_from_input(
3180 Some("/repo"),
3181 Some(42),
3182 0,
3183 "Bash",
3184 &json!({"command": command}),
3185 None,
3186 );
3187 assert_eq!(event.effect, "write", "{command}");
3188 assert!(
3189 event
3190 .path_refs
3191 .iter()
3192 .any(|reference| reference.access == "write"),
3193 "{command}"
3194 );
3195 }
3196
3197 let command_fragment = tool_event_from_input(
3198 Some("/repo"),
3199 Some(42),
3200 0,
3201 "exec_command",
3202 &json!({
3203 "cmd": "bash -lc 'cargo run --manifest-path collector/Cargo.toml -- top'",
3204 "path": "cargo run --manifest-path collector/Cargo.toml -- top",
3205 "pattern": "*.rs"
3206 }),
3207 None,
3208 );
3209 assert!(command_fragment.path_refs.is_empty());
3210
3211 let compound = tool_event_from_input(
3212 Some("/repo"),
3213 Some(42),
3214 0,
3215 "Bash",
3216 &json!({"command": "cd /tmp && cp src/a.rs src/b.rs | tee logs/copied.txt"}),
3217 None,
3218 );
3219 assert!(compound.path_refs.is_empty());
3220
3221 let nested = tool_event_from_input(
3222 Some("/repo"),
3223 Some(42),
3224 0,
3225 "Bash",
3226 &json!({"command": "bash -lc 'cp src/a.rs src/b.rs'"}),
3227 None,
3228 );
3229 assert_eq!(
3230 nested
3231 .path_refs
3232 .iter()
3233 .map(|reference| (reference.path.as_str(), reference.access.as_str()))
3234 .collect::<Vec<_>>(),
3235 vec![("src/a.rs", "read"), ("src/b.rs", "write")]
3236 );
3237
3238 let search = tool_event_from_input(
3239 Some("/repo"),
3240 Some(42),
3241 0,
3242 "Bash",
3243 &json!({"command": "rg '[/agentsight/usage/,' collector/src/main.rs"}),
3244 None,
3245 );
3246 assert_eq!(search.path_refs.len(), 1);
3247 assert_eq!(search.path_refs[0].path, "collector/src/main.rs");
3248
3249 for command in [
3250 "git diff origin/master...HEAD -- collector/src/main.rs",
3251 "git show HEAD:collector/src/main.rs",
3252 "gh api repos/eunomia-bpf/agentsight/pulls/109/comments",
3253 "docker pull ghcr.io/eunomia-bpf/agentsight:latest",
3254 "printf '#!/bin/bash'",
3255 "for f in bpf/*; do echo $f; done",
3256 "echo 100644,$blob,collector/src/main.rs",
3257 "echo CLI/TUI/Web",
3258 ] {
3259 let event = tool_event_from_input(
3260 Some("/repo"),
3261 Some(42),
3262 0,
3263 "exec_command",
3264 &json!({"cmd": command}),
3265 None,
3266 );
3267 assert!(
3268 event.path_refs.is_empty(),
3269 "{command}: {:?}",
3270 event.path_refs
3271 );
3272 }
3273 }
3274
3275 #[test]
3276 fn subdirectory_paths_remain_resolvable_inside_the_repository() {
3277 let root = std::env::temp_dir().join(format!(
3278 "agent-session-subdir-path-test-{}",
3279 std::process::id()
3280 ));
3281 let subdir = root.join("nested");
3282 std::fs::create_dir_all(root.join(".git")).expect("create git marker");
3283 std::fs::create_dir_all(&subdir).expect("create cwd");
3284
3285 assert_eq!(
3286 repository_relative_path(&subdir, "../src/lib.rs").as_deref(),
3287 Some("../src/lib.rs")
3288 );
3289 assert_eq!(
3290 repository_relative_path(&subdir, root.join("src/lib.rs").to_str().unwrap()).as_deref(),
3291 Some("../src/lib.rs")
3292 );
3293 assert!(repository_relative_path(&subdir, "/outside/secret.txt").is_none());
3294
3295 let explicit_cd = tool_event_from_input(
3296 Some(root.to_str().unwrap()),
3297 Some(42),
3298 0,
3299 "exec_command",
3300 &json!({
3301 "cmd": "cd collector && cp ../bpf/stdiocap vendor/bpf/stdiocap"
3302 }),
3303 None,
3304 );
3305 assert!(
3306 explicit_cd.path_refs.iter().any(|reference| {
3307 reference.path == "bpf/stdiocap" && reference.access == "read"
3308 })
3309 );
3310 assert!(explicit_cd.path_refs.iter().any(|reference| {
3311 reference.path == "collector/vendor/bpf/stdiocap" && reference.access == "write"
3312 }));
3313
3314 let command_workdir = tool_event_from_input(
3315 Some(root.to_str().unwrap()),
3316 Some(42),
3317 0,
3318 "exec_command",
3319 &json!({
3320 "cmd": "cp ../bpf/stdiocap vendor/bpf/stdiocap",
3321 "workdir": subdir
3322 }),
3323 None,
3324 );
3325 assert!(
3326 command_workdir.path_refs.iter().any(|reference| {
3327 reference.path == "bpf/stdiocap" && reference.access == "read"
3328 })
3329 );
3330 assert!(command_workdir.path_refs.iter().any(|reference| {
3331 reference.path == "nested/vendor/bpf/stdiocap" && reference.access == "write"
3332 }));
3333
3334 for private_path in ["~/.cargo/registry/src", "$HOME/.codex/sessions"] {
3335 let event = tool_event_from_input(
3336 Some(root.to_str().unwrap()),
3337 Some(42),
3338 0,
3339 "exec_command",
3340 &json!({
3341 "cmd": format!("cat {private_path}"),
3342 "workdir": subdir
3343 }),
3344 None,
3345 );
3346 assert!(event.path_refs.is_empty(), "{private_path}");
3347 }
3348 std::fs::remove_dir_all(root).expect("remove path fixture");
3349 }
3350
3351 #[test]
3352 fn tool_events_preserve_private_body_free_path_and_edit_evidence() {
3353 let event = tool_event_from_input(
3354 Some("/repo"),
3355 Some(42),
3356 0,
3357 "Edit",
3358 &json!({
3359 "file_path": "/repo/src/lib.rs",
3360 "old_string": "fn old() {}\n",
3361 "new_string": "fn new() {}\n"
3362 }),
3363 Some("call-1".to_string()),
3364 );
3365
3366 assert_eq!(event.effect, "write");
3367 assert_eq!(event.path_refs.len(), 1);
3368 assert_eq!(event.path_refs[0].path, "src/lib.rs");
3369 assert_eq!(event.path_refs[0].access, "write");
3370 let summary = event.edit_summary.expect("edit summary");
3371 assert_eq!(summary.payload_kind, "replace");
3372 assert_eq!(summary.removed_lines, 1);
3373 assert_eq!(summary.added_lines, 1);
3374 assert_ne!(summary.before_hash, summary.after_hash);
3375
3376 let encoded = serde_json::to_string(&summary).expect("serialize summary");
3377 assert!(!encoded.contains("fn old"));
3378 assert!(!encoded.contains("fn new"));
3379 }
3380
3381 #[test]
3382 fn patch_paths_keep_repository_scope_and_drop_external_paths() {
3383 let patch = concat!(
3384 "*** Begin Patch\n",
3385 "*** Update File: src/main.rs\n",
3386 "@@\n-old\n+new\n",
3387 "*** End Patch"
3388 );
3389 let event = tool_event_from_input(
3390 Some("/repo"),
3391 Some(42),
3392 0,
3393 "apply_patch",
3394 &json!({"input": patch, "path": "/outside/secret.txt"}),
3395 None,
3396 );
3397
3398 assert_eq!(event.path_refs.len(), 1);
3399 assert_eq!(event.path_refs[0].path, "src/main.rs");
3400 assert_eq!(event.edit_summary.expect("patch summary").added_lines, 1);
3401 }
3402
3403 #[test]
3404 fn repository_paths_reject_home_and_environment_relative_tokens() {
3405 let event = tool_event_from_input(
3406 Some("/repo"),
3407 Some(42),
3408 0,
3409 "Bash",
3410 &json!({
3411 "command": "rg needle ~/.claude/projects $HOME/.codex ~/workspace/other .git/HEAD src/lib.rs"
3412 }),
3413 None,
3414 );
3415
3416 assert_eq!(event.path_refs.len(), 1);
3417 assert_eq!(event.path_refs[0].path, "src/lib.rs");
3418 assert!(event.path_refs.iter().all(|reference| {
3419 !reference.path.starts_with('~') && !reference.path.starts_with('$')
3420 }));
3421 }
3422
3423 #[test]
3424 fn multi_file_and_multi_hunk_patches_do_not_claim_one_exact_edit() {
3425 let multi_file = concat!(
3426 "*** Begin Patch\n",
3427 "*** Update File: src/a.rs\n",
3428 "@@\n-old-a\n+new-a\n",
3429 "*** Update File: src/b.rs\n",
3430 "@@\n-old-b\n+new-b\n",
3431 "*** End Patch"
3432 );
3433 let event = tool_event_from_input(
3434 Some("/repo"),
3435 Some(42),
3436 0,
3437 "apply_patch",
3438 &json!({"input": multi_file}),
3439 None,
3440 );
3441 assert_eq!(event.path_refs.len(), 2);
3442 assert!(event.edit_summary.is_none());
3443
3444 let multi_hunk = concat!(
3445 "*** Begin Patch\n",
3446 "*** Update File: src/a.rs\n",
3447 "@@\n-old-a\n+new-a\n",
3448 "@@\n-old-b\n+new-b\n",
3449 "*** End Patch"
3450 );
3451 let event = tool_event_from_input(
3452 Some("/repo"),
3453 Some(42),
3454 0,
3455 "apply_patch",
3456 &json!({"input": multi_hunk}),
3457 None,
3458 );
3459 assert_eq!(event.path_refs.len(), 1);
3460 assert!(event.edit_summary.is_none());
3461 }
3462
3463 #[test]
3464 fn gemini_project_hash_and_relative_paths_survive_normalization() {
3465 let content = r#"{
3466 "sessionId":"gemini-session",
3467 "projectHash":"abc123",
3468 "startTime":"2026-07-14T10:00:00Z",
3469 "lastUpdated":"2026-07-14T10:01:00Z",
3470 "messages":[{
3471 "type":"gemini",
3472 "timestamp":"2026-07-14T10:00:30Z",
3473 "model":"gemini-test",
3474 "content":"done",
3475 "toolCalls":[{
3476 "id":"tool-1",
3477 "name":"read_file",
3478 "args":{"file_path":"src/lib.rs"},
3479 "status":"success"
3480 }]
3481 }]
3482 }"#;
3483 let session = parse_session_content(
3484 AGENT_GEMINI,
3485 Path::new("/tmp/session-gemini.json"),
3486 UNIX_EPOCH,
3487 content,
3488 )
3489 .expect("gemini session");
3490
3491 assert_eq!(session.project_hash.as_deref(), Some("abc123"));
3492 assert_eq!(session.events.tools[0].effect, "read");
3493 assert_eq!(session.events.tools[0].status, "success");
3494 assert_eq!(session.events.tools[0].path_refs[0].path, "src/lib.rs");
3495 }
3496
3497 #[test]
3498 fn codex_usage_updates_coalesce_within_a_prompt() {
3499 let content = concat!(
3500 r#"{"type":"turn_context","payload":{"model":"gpt-5","cwd":"/repo"}}"#,
3501 "\n",
3502 r#"{"timestamp":"2026-07-14T10:00:00Z","type":"event_msg","payload":{"type":"user_message","message":"work"}}"#,
3503 "\n",
3504 r#"{"timestamp":"2026-07-14T10:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"output_tokens":2,"total_tokens":12}}}}"#,
3505 "\n",
3506 r#"{"timestamp":"2026-07-14T10:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":20,"output_tokens":4,"total_tokens":24}}}}"#,
3507 );
3508 let session = parse_session_content(
3509 AGENT_CODEX,
3510 Path::new("/tmp/session-codex.jsonl"),
3511 UNIX_EPOCH,
3512 content,
3513 )
3514 .expect("codex session");
3515
3516 assert_eq!(session.events.llm_responses.len(), 1);
3517 assert_eq!(session.events.llm_responses[0].total_tokens, 24);
3518 assert_eq!(session.events.llm_responses[0].tag, "usage");
3519 }
3520
3521 #[test]
3522 fn codex_patch_apply_events_expose_write_paths_without_bodies() {
3523 let content = concat!(
3524 r#"{"type":"turn_context","payload":{"model":"gpt-5","cwd":"/repo"}}"#,
3525 "\n",
3526 r#"{"timestamp":"2026-07-14T10:00:00Z","type":"event_msg","payload":{"type":"user_message","message":"edit"}}"#,
3527 "\n",
3528 r#"{"timestamp":"2026-07-14T10:00:01Z","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"patch-1","success":true,"changes":{"/repo/src/lib.rs":{"type":"update","unified_diff":"@@ -1 +1 @@\n-old\n+new"}}}}"#,
3529 );
3530 let session = parse_session_content(
3531 AGENT_CODEX,
3532 Path::new("/tmp/session-codex.jsonl"),
3533 UNIX_EPOCH,
3534 content,
3535 )
3536 .expect("codex session");
3537
3538 let event = &session.events.tools[0];
3539 assert_eq!(event.effect, "write");
3540 assert_eq!(event.status, "success");
3541 assert_eq!(event.path_refs[0].path, "src/lib.rs");
3542 assert_eq!(event.edit_summary.as_ref().unwrap().payload_kind, "patch");
3543 }
3544
3545 #[test]
3546 fn codex_patch_completion_updates_the_existing_tool_call() {
3547 let content = concat!(
3548 r#"{"type":"turn_context","payload":{"model":"gpt-5","cwd":"/repo"}}"#,
3549 "\n",
3550 r#"{"timestamp":"2026-07-14T10:00:00Z","type":"response_item","payload":{"type":"custom_tool_call","name":"apply_patch","call_id":"patch-1","arguments":"{\"input\":\"*** Begin Patch\\n*** Update File: src/lib.rs\\n@@\\n-old\\n+new\\n*** End Patch\"}"}}"#,
3551 "\n",
3552 r#"{"timestamp":"2026-07-14T10:00:01Z","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"patch-1","success":true,"changes":{"/repo/src/lib.rs":{"type":"update","unified_diff":"@@ -1 +1 @@\n-old\n+new"}}}}"#,
3553 );
3554 let session = parse_session_content(
3555 AGENT_CODEX,
3556 Path::new("/tmp/session-codex.jsonl"),
3557 UNIX_EPOCH,
3558 content,
3559 )
3560 .expect("codex session");
3561
3562 assert_eq!(session.events.tools.len(), 1);
3563 assert_eq!(session.events.tools[0].call_id.as_deref(), Some("patch-1"));
3564 assert_eq!(session.events.tools[0].status, "success");
3565 assert_eq!(session.events.tools[0].path_refs[0].path, "src/lib.rs");
3566 }
3567}