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