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