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