Skip to main content

agent_session/
parser.rs

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