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