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 std::collections::{BTreeMap, HashSet};
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use crate::types::{AgentSession, SessionCandidate, SessionDirStat, TokenUsage};
13use crate::{AGENT_CLAUDE, AGENT_CODEX, AGENT_GEMINI};
14
15/// Discover all session files in the user's home directory.
16pub fn discover_session_files() -> Vec<SessionCandidate> {
17    user_home_dir()
18        .as_deref()
19        .map(discover_session_files_in_home)
20        .unwrap_or_default()
21}
22
23/// Discover session files under a specific home directory.
24pub fn discover_session_files_in_home(home: &Path) -> Vec<SessionCandidate> {
25    let roots = [
26        (AGENT_CLAUDE, home.join(".claude/projects")),
27        (AGENT_CODEX, home.join(".codex/sessions")),
28        (AGENT_GEMINI, home.join(".gemini/tmp")),
29    ];
30    let mut out = Vec::new();
31    for (agent, dir) in roots {
32        walk_agent_files(agent, &dir, &mut |path, meta| {
33            out.push(SessionCandidate {
34                agent,
35                path: path.to_path_buf(),
36                updated: meta.modified().unwrap_or(UNIX_EPOCH),
37            });
38        });
39    }
40    out
41}
42
43/// Count sessions and bytes per agent directory.
44pub fn count_session_dirs() -> Vec<SessionDirStat> {
45    let Some(home) = user_home_dir() else {
46        return Vec::new();
47    };
48    [
49        (AGENT_CLAUDE, home.join(".claude/projects")),
50        (AGENT_CODEX, home.join(".codex/sessions")),
51        (AGENT_GEMINI, home.join(".gemini/tmp")),
52    ]
53    .into_iter()
54    .filter_map(|(agent, dir)| {
55        let (mut sessions, mut bytes) = (0usize, 0u64);
56        walk_agent_files(agent, &dir, &mut |_, meta| {
57            sessions += 1;
58            bytes += meta.len();
59        });
60        (sessions > 0).then_some(SessionDirStat {
61            agent,
62            dir,
63            sessions,
64            bytes,
65        })
66    })
67    .collect()
68}
69
70/// Parse a session file from a candidate.
71pub fn parse_session_file(candidate: &SessionCandidate) -> Option<AgentSession> {
72    let content = fs::read_to_string(&candidate.path).ok()?;
73    parse_session_content(
74        candidate.agent,
75        &candidate.path,
76        candidate.updated,
77        &content,
78    )
79}
80
81/// Parse a session file by path, detecting the agent type automatically.
82pub fn parse_session_path(path: &Path) -> Option<AgentSession> {
83    let agent = agent_source_for_path(path)?;
84    let updated = fs::metadata(path)
85        .and_then(|metadata| metadata.modified())
86        .unwrap_or(UNIX_EPOCH);
87    parse_session_file(&SessionCandidate {
88        agent,
89        path: path.to_path_buf(),
90        updated,
91    })
92}
93
94/// Parse session content given raw content string.
95pub fn parse_session_content(
96    agent: &str,
97    path: &Path,
98    updated: SystemTime,
99    content: &str,
100) -> Option<AgentSession> {
101    if agent == AGENT_GEMINI {
102        parse_gemini_json(path, updated, content)
103    } else {
104        parse_jsonl(agent, path, updated, content)
105    }
106}
107
108/// Extract a session log path from a string (e.g., from /proc/fd).
109pub fn session_log_path_from_str(raw: &str) -> Option<PathBuf> {
110    let trimmed = raw.trim().trim_end_matches(" (deleted)");
111    if trimmed.is_empty() {
112        return None;
113    }
114    let path = Path::new(trimmed);
115    if !path.is_absolute() || !is_agent_session_file(path) {
116        return None;
117    }
118    agent_source_for_path(path).map(|_| normalize_session_log_path(path))
119}
120
121/// Canonicalize a session log path.
122pub fn normalize_session_log_path(path: &Path) -> PathBuf {
123    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
124}
125
126/// Detect which agent a session file belongs to based on its path.
127pub fn agent_source_for_path(path: &Path) -> Option<&'static str> {
128    let value = path.to_string_lossy();
129    if value.contains("/.claude/") && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
130    {
131        Some(AGENT_CLAUDE)
132    } else if value.contains("/.codex/")
133        && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
134    {
135        Some(AGENT_CODEX)
136    } else if value.contains("/.gemini/")
137        && path.extension().and_then(|ext| ext.to_str()) == Some("json")
138    {
139        Some(AGENT_GEMINI)
140    } else {
141        None
142    }
143}
144
145/// Generate a fixture session path for testing.
146pub fn fixture_session_path(agent: &str, home: &Path) -> Option<PathBuf> {
147    match agent {
148        AGENT_CLAUDE => Some(home.join(".claude/projects/test/session.jsonl")),
149        AGENT_CODEX => Some(home.join(".codex/sessions/2026/06/02/session.jsonl")),
150        AGENT_GEMINI => Some(home.join(".gemini/tmp/test/chats/session-test.json")),
151        _ => None,
152    }
153}
154
155/// Check if a target path is the Codex CLI entrypoint.
156pub fn is_codex_cli_entrypoint(target: Option<&str>) -> bool {
157    target.is_some_and(|target| {
158        Path::new(target).file_name().and_then(|name| name.to_str()) == Some("codex")
159            && !target.contains("/node_modules/")
160    })
161}
162
163/// Extract the prompt from a Codex exec command.
164pub fn codex_exec_prompt(command: &str) -> Option<String> {
165    let mut args = command.split_once(" exec ")?.1.trim();
166    while let Some(rest) = strip_codex_exec_option(args) {
167        args = rest.trim_start();
168    }
169    (!args.starts_with('-'))
170        .then(|| args.trim_matches(['"', '\'']))
171        .and_then(clean_prompt_text)
172}
173
174// ---------------------------------------------------------------------------
175// Internal parsing implementation
176// ---------------------------------------------------------------------------
177
178fn parse_jsonl(
179    agent: &str,
180    path: &Path,
181    updated: SystemTime,
182    content: &str,
183) -> Option<AgentSession> {
184    let mut acc = SessionAccumulator::new(agent, path, updated);
185    let mut codex_model = String::new();
186    let mut claude_message_models = BTreeMap::<String, TokenUsage>::new();
187    let mut claude_seen_usage = HashSet::new();
188
189    for line in content.lines() {
190        let Ok(obj) = serde_json::from_str::<Value>(line) else {
191            continue;
192        };
193        let (session_id, conversation_id) = local_session_ids(&obj);
194        if let Some(id) = session_id {
195            acc.session_id = id;
196        }
197        if let Some(id) = conversation_id {
198            acc.conversation_id = Some(id);
199        }
200        if acc.cwd.is_none() {
201            acc.cwd = obj
202                .get("cwd")
203                .and_then(Value::as_str)
204                .or_else(|| obj.pointer("/payload/cwd").and_then(Value::as_str))
205                .filter(|s| !s.is_empty())
206                .map(ToString::to_string);
207        }
208        if let Some(ts) = obj.get("timestamp").and_then(Value::as_str) {
209            acc.last_message_at = Some(ts.to_string());
210            acc.end_timestamp_ms = iso_ms(ts).or(acc.end_timestamp_ms);
211        }
212        let typ = obj.get("type").and_then(Value::as_str).unwrap_or("");
213        match (agent, typ) {
214            (AGENT_CLAUDE, "result") => {
215                acc.duration_ms = json_u64(&obj, "duration_ms");
216                if let Some(model_usage) = obj.get("modelUsage").and_then(Value::as_object) {
217                    for (name, usage) in model_usage {
218                        acc.model.get_or_insert_with(|| name.clone());
219                        acc.add_usage(
220                            name,
221                            json_i64(usage, "inputTokens"),
222                            json_i64(usage, "outputTokens"),
223                            json_i64(usage, "cacheCreationInputTokens"),
224                            json_i64(usage, "cacheReadInputTokens"),
225                            0,
226                        );
227                    }
228                }
229            }
230            (AGENT_CLAUDE, "assistant") => {
231                if let Some(name) = obj.pointer("/message/model").and_then(Value::as_str) {
232                    acc.model.get_or_insert_with(|| name.to_string());
233                }
234                if let Some(usage) = obj.pointer("/message/usage")
235                    && claude_seen_usage.insert(claude_usage_key(&obj))
236                {
237                    let name = obj
238                        .pointer("/message/model")
239                        .and_then(Value::as_str)
240                        .unwrap_or("unknown");
241                    add_usage(
242                        &mut claude_message_models,
243                        name,
244                        json_i64(usage, "input_tokens"),
245                        json_i64(usage, "output_tokens"),
246                        json_i64(usage, "cache_creation_input_tokens"),
247                        json_i64(usage, "cache_read_input_tokens"),
248                        0,
249                    );
250                }
251                if let Some(items) = obj.pointer("/message/content").and_then(Value::as_array) {
252                    for item in items
253                        .iter()
254                        .filter(|item| item.get("type").and_then(Value::as_str) == Some("tool_use"))
255                    {
256                        let name = item.get("name").and_then(Value::as_str).unwrap_or("?");
257                        acc.add_tool(name);
258                        if let Some(fp) = item
259                            .pointer("/input/file_path")
260                            .and_then(Value::as_str)
261                            .filter(|s| !is_noise_path(s))
262                        {
263                            acc.add_file(fp);
264                        }
265                    }
266                }
267            }
268            (AGENT_CLAUDE, "queue-operation") if acc.prompt_preview.is_none() => {
269                if obj.get("operation").and_then(Value::as_str) == Some("enqueue")
270                    && let Some(text) = obj.get("content").and_then(Value::as_str)
271                    && let Some(text) = clean_prompt_text(text)
272                {
273                    acc.prompt_preview = Some(text);
274                }
275            }
276            (AGENT_CLAUDE, "last-prompt") if acc.prompt_preview.is_none() => {
277                if let Some(text) = obj.get("lastPrompt").and_then(Value::as_str)
278                    && let Some(text) = clean_prompt_text(text)
279                {
280                    acc.prompt_preview = Some(text);
281                }
282            }
283            (AGENT_CLAUDE, "user") => {
284                if acc.prompt_preview.is_none()
285                    && !is_claude_tool_result(&obj)
286                    && let Some(text) =
287                        local_message_preview(obj.pointer("/message/content").unwrap_or(&obj))
288                {
289                    acc.prompt_preview = Some(text);
290                }
291            }
292            (AGENT_CODEX, "turn_context") => {
293                if let Some(name) = obj.pointer("/payload/model").and_then(Value::as_str) {
294                    codex_model = name.to_string();
295                    acc.model = Some(name.to_string());
296                }
297            }
298            (AGENT_CODEX, "event_msg") => {
299                if obj.pointer("/payload/type").and_then(Value::as_str) == Some("token_count")
300                    && let Some(usage) = obj.pointer("/payload/info/total_token_usage")
301                {
302                    let name = if codex_model.is_empty() {
303                        "unknown"
304                    } else {
305                        &codex_model
306                    };
307                    acc.set_usage(
308                        name,
309                        json_i64(usage, "input_tokens"),
310                        json_i64(usage, "output_tokens"),
311                        0,
312                        0,
313                        json_i64(usage, "total_tokens"),
314                    );
315                }
316            }
317            (AGENT_CODEX, "response_item")
318                if obj.pointer("/payload/type").and_then(Value::as_str)
319                    == Some("function_call") =>
320            {
321                let name = obj
322                    .pointer("/payload/name")
323                    .and_then(Value::as_str)
324                    .unwrap_or("?");
325                acc.add_tool(name);
326            }
327            (AGENT_CODEX, "message" | "input" | "user") => {
328                if let Some(text) = local_message_preview(&obj) {
329                    acc.prompt_preview = Some(text);
330                }
331            }
332            _ if acc.prompt_preview.is_none() && typ.contains("user") => {
333                if let Some(text) = local_message_preview(&obj) {
334                    acc.prompt_preview = Some(text);
335                }
336            }
337            _ => {}
338        }
339    }
340
341    if acc.model_usage.is_empty() {
342        acc.model_usage = claude_message_models;
343    }
344    acc.finish()
345}
346
347fn parse_gemini_json(path: &Path, updated: SystemTime, content: &str) -> Option<AgentSession> {
348    let root: Value = serde_json::from_str(content).ok()?;
349    let mut acc = SessionAccumulator::new(AGENT_GEMINI, path, updated);
350    if let Some(id) = root.get("sessionId").and_then(Value::as_str) {
351        acc.session_id = id.to_string();
352        acc.conversation_id = Some(id.to_string());
353    }
354    acc.start_timestamp_ms = root
355        .get("startTime")
356        .and_then(Value::as_str)
357        .and_then(iso_ms);
358    acc.end_timestamp_ms = root
359        .get("lastUpdated")
360        .and_then(Value::as_str)
361        .and_then(iso_ms)
362        .or(acc.start_timestamp_ms);
363    acc.duration_ms = acc
364        .start_timestamp_ms
365        .zip(acc.end_timestamp_ms)
366        .map(|(start, end)| end.saturating_sub(start))
367        .unwrap_or_default();
368
369    let Some(messages) = root.get("messages").and_then(Value::as_array) else {
370        return acc.finish();
371    };
372    for msg in messages {
373        if let Some(ts) = msg.get("timestamp").and_then(Value::as_str) {
374            acc.last_message_at = Some(ts.to_string());
375        }
376        match msg.get("type").and_then(Value::as_str) {
377            Some("user") if acc.prompt_preview.is_none() => {
378                if let Some(text) = local_message_preview(msg.get("content").unwrap_or(msg)) {
379                    acc.prompt_preview = Some(text);
380                }
381            }
382            Some("gemini") | Some("assistant") | Some("model") => {
383                if let Some(model) = msg.get("model").and_then(Value::as_str) {
384                    acc.model.get_or_insert_with(|| model.to_string());
385                    if let Some(tokens) = msg.get("tokens") {
386                        acc.add_usage(
387                            model,
388                            json_i64(tokens, "input"),
389                            json_i64(tokens, "output"),
390                            0,
391                            json_i64(tokens, "cached"),
392                            json_i64(tokens, "total"),
393                        );
394                    }
395                }
396                if let Some(tool_calls) = msg.get("toolCalls").and_then(Value::as_array) {
397                    for call in tool_calls {
398                        let name = call.get("name").and_then(Value::as_str).unwrap_or("?");
399                        acc.add_tool(name);
400                        if let Some(path) = find_file_arg(call).filter(|path| !is_noise_path(path))
401                        {
402                            acc.add_file(path);
403                        }
404                    }
405                }
406            }
407            _ => {}
408        }
409    }
410    acc.finish()
411}
412
413struct SessionAccumulator {
414    agent_type: String,
415    session_id: String,
416    conversation_id: Option<String>,
417    path: PathBuf,
418    updated: SystemTime,
419    start_timestamp_ms: Option<u64>,
420    end_timestamp_ms: Option<u64>,
421    model: Option<String>,
422    model_usage: BTreeMap<String, TokenUsage>,
423    tools: BTreeMap<String, usize>,
424    files: BTreeMap<String, usize>,
425    prompt_preview: Option<String>,
426    duration_ms: u64,
427    cwd: Option<String>,
428    last_message_at: Option<String>,
429}
430
431impl SessionAccumulator {
432    fn new(agent: &str, path: &Path, updated: SystemTime) -> Self {
433        let normalized = normalize_session_log_path(path);
434        let session_id = path
435            .file_stem()
436            .and_then(|stem| stem.to_str())
437            .unwrap_or("session")
438            .to_string();
439        Self {
440            agent_type: agent.to_string(),
441            session_id,
442            conversation_id: None,
443            path: normalized.clone(),
444            updated,
445            start_timestamp_ms: None,
446            end_timestamp_ms: Some(system_time_ms(updated)),
447            model: None,
448            model_usage: BTreeMap::new(),
449            tools: BTreeMap::new(),
450            files: BTreeMap::new(),
451            prompt_preview: None,
452            duration_ms: 0,
453            cwd: None,
454            last_message_at: None,
455        }
456    }
457
458    fn add_usage(
459        &mut self,
460        model: &str,
461        input: i64,
462        output: i64,
463        cache_creation: i64,
464        cache_read: i64,
465        total: i64,
466    ) {
467        add_usage(
468            &mut self.model_usage,
469            model,
470            input,
471            output,
472            cache_creation,
473            cache_read,
474            total,
475        );
476    }
477
478    fn set_usage(
479        &mut self,
480        model: &str,
481        input: i64,
482        output: i64,
483        cache_creation: i64,
484        cache_read: i64,
485        total: i64,
486    ) {
487        let mut usage = TokenUsage::default();
488        usage.add(input, output, cache_creation, cache_read, total);
489        self.model_usage.insert(model.to_string(), usage);
490    }
491
492    fn add_tool(&mut self, name: &str) {
493        *self.tools.entry(name.to_string()).or_default() += 1;
494    }
495
496    fn add_file(&mut self, path: &str) {
497        *self.files.entry(path.to_string()).or_default() += 1;
498    }
499
500    fn finish(self) -> Option<AgentSession> {
501        let token_usage =
502            self.model_usage
503                .values()
504                .fold(TokenUsage::default(), |mut total, usage| {
505                    total.input_tokens += usage.input_tokens;
506                    total.output_tokens += usage.output_tokens;
507                    total.cache_creation_tokens += usage.cache_creation_tokens;
508                    total.cache_read_tokens += usage.cache_read_tokens;
509                    total.total_tokens += usage.total_tokens;
510                    total
511                });
512        if token_usage.total_tokens == 0
513            && self.tools.is_empty()
514            && self.prompt_preview.is_none()
515            && self.model.is_none()
516        {
517            return None;
518        }
519        let display_id = format!("{}:{}", self.agent_type, short_session_id(&self.session_id));
520        Some(AgentSession {
521            agent_type: self.agent_type,
522            session_id: self.session_id,
523            conversation_id: self.conversation_id,
524            display_id,
525            path: self.path,
526            updated: self.updated,
527            start_timestamp_ms: self
528                .start_timestamp_ms
529                .or_else(|| Some(system_time_ms(self.updated).saturating_sub(self.duration_ms))),
530            end_timestamp_ms: self.end_timestamp_ms,
531            model: self.model,
532            usage: token_usage,
533            model_usage: self.model_usage,
534            tools: self.tools,
535            files: self.files,
536            prompt_preview: self.prompt_preview,
537            duration_ms: self.duration_ms,
538            cwd: self.cwd,
539            last_message_at: self.last_message_at,
540        })
541    }
542}
543
544// ---------------------------------------------------------------------------
545// Helper functions
546// ---------------------------------------------------------------------------
547
548fn walk_agent_files(agent: &'static str, dir: &Path, f: &mut dyn FnMut(&Path, &fs::Metadata)) {
549    let Ok(entries) = fs::read_dir(dir) else {
550        return;
551    };
552    for entry in entries.flatten() {
553        let path = entry.path();
554        if path.is_dir() {
555            walk_agent_files(agent, &path, f);
556        } else if is_agent_file_for(agent, &path)
557            && let Ok(meta) = path.metadata()
558        {
559            f(&path, &meta);
560        }
561    }
562}
563
564fn is_agent_session_file(path: &Path) -> bool {
565    agent_source_for_path(path).is_some()
566}
567
568fn is_agent_file_for(agent: &str, path: &Path) -> bool {
569    match agent {
570        AGENT_CLAUDE | AGENT_CODEX => {
571            path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")
572        }
573        AGENT_GEMINI => {
574            path.extension().and_then(|ext| ext.to_str()) == Some("json")
575                && path
576                    .file_name()
577                    .and_then(|name| name.to_str())
578                    .is_some_and(|name| name.starts_with("session-"))
579                && path.to_string_lossy().contains("/chats/")
580        }
581        _ => false,
582    }
583}
584
585pub(crate) fn user_home_dir() -> Option<PathBuf> {
586    std::env::var("SUDO_USER")
587        .ok()
588        .and_then(|user| {
589            fs::read_to_string("/etc/passwd").ok().and_then(|passwd| {
590                passwd
591                    .lines()
592                    .find(|line| line.starts_with(&format!("{user}:")))
593                    .and_then(|line| line.split(':').nth(5))
594                    .map(PathBuf::from)
595            })
596        })
597        .or_else(dirs::home_dir)
598}
599
600fn add_usage(
601    models: &mut BTreeMap<String, TokenUsage>,
602    model: &str,
603    input: i64,
604    output: i64,
605    cache_creation: i64,
606    cache_read: i64,
607    total: i64,
608) {
609    models.entry(model.to_string()).or_default().add(
610        input,
611        output,
612        cache_creation,
613        cache_read,
614        total,
615    );
616}
617
618fn local_session_ids(obj: &Value) -> (Option<String>, Option<String>) {
619    let session_id = first_json_string(
620        obj,
621        &["sessionId", "session_id"],
622        &["/payload/session_id", "/payload/sessionId"],
623    );
624    let conversation_id = first_json_string(
625        obj,
626        &["conversation_id", "conversationId", "thread_id", "threadId"],
627        &[
628            "/payload/conversation_id",
629            "/payload/conversationId",
630            "/payload/thread_id",
631            "/payload/threadId",
632        ],
633    )
634    .or_else(|| session_id.clone());
635    (
636        session_id.or_else(|| conversation_id.clone()),
637        conversation_id,
638    )
639}
640
641fn first_json_string(obj: &Value, keys: &[&str], pointers: &[&str]) -> Option<String> {
642    keys.iter()
643        .filter_map(|key| obj.get(*key).and_then(Value::as_str))
644        .chain(
645            pointers
646                .iter()
647                .filter_map(|pointer| obj.pointer(pointer).and_then(Value::as_str)),
648        )
649        .find(|value| !value.is_empty())
650        .map(str::to_string)
651}
652
653fn strip_codex_exec_option(args: &str) -> Option<&str> {
654    let (head, rest) = args.split_once(char::is_whitespace).unwrap_or((args, ""));
655    match head {
656        "--json" | "--skip-git-repo-check" | "--ephemeral" => Some(rest),
657        "-C" | "-a" | "-s" | "-m" | "-c" | "-p" => rest
658            .trim_start()
659            .split_once(char::is_whitespace)
660            .map(|(_, rest)| rest),
661        _ => None,
662    }
663}
664
665fn claude_usage_key(obj: &Value) -> String {
666    obj.get("requestId")
667        .or_else(|| obj.pointer("/message/id"))
668        .or_else(|| obj.get("uuid"))
669        .and_then(Value::as_str)
670        .unwrap_or("usage")
671        .to_string()
672}
673
674fn local_message_preview(value: &Value) -> Option<String> {
675    let mut parts = Vec::new();
676    collect_local_text(value, &mut parts);
677    clean_prompt_text(&parts.join(" "))
678}
679
680fn collect_local_text(value: &Value, out: &mut Vec<String>) {
681    match value {
682        Value::String(text) => out.push(text.clone()),
683        Value::Array(items) => {
684            for item in items {
685                collect_local_text(item, out);
686            }
687        }
688        Value::Object(obj) => {
689            if obj.get("type").and_then(Value::as_str).is_some_and(|typ| {
690                typ == "tool_use" || typ == "function_call" || typ == "tool_result"
691            }) {
692                return;
693            }
694            for key in ["text", "content", "message", "input", "prompt"] {
695                if let Some(value) = obj.get(key) {
696                    collect_local_text(value, out);
697                }
698            }
699        }
700        _ => {}
701    }
702}
703
704fn is_claude_tool_result(obj: &Value) -> bool {
705    obj.get("toolUseResult").is_some()
706        || obj.get("tool_use_result").is_some()
707        || obj
708            .pointer("/message/content")
709            .and_then(Value::as_array)
710            .is_some_and(|items| {
711                items
712                    .iter()
713                    .any(|item| item.get("type").and_then(Value::as_str) == Some("tool_result"))
714            })
715}
716
717fn find_file_arg(value: &Value) -> Option<&str> {
718    match value {
719        Value::Object(obj) => {
720            for key in ["file_path", "path", "filepath"] {
721                if let Some(path) = obj.get(key).and_then(Value::as_str) {
722                    return Some(path);
723                }
724            }
725            obj.values().find_map(find_file_arg)
726        }
727        Value::Array(items) => items.iter().find_map(find_file_arg),
728        _ => None,
729    }
730}
731
732fn is_noise_path(path: &str) -> bool {
733    const NOISE: &[&str] = &[
734        "/.claude/",
735        "/.codex/",
736        "/.gemini/",
737        "/.git/",
738        "/node_modules/",
739        "/.npm/",
740        "/.cache/",
741        "CLAUDE.md",
742        "AGENTS.md",
743    ];
744    NOISE.iter().any(|pat| path.contains(pat))
745}
746
747fn clean_prompt_text(text: &str) -> Option<String> {
748    let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
749    let text = text
750        .strip_prefix("<session>")
751        .and_then(|text| text.strip_suffix("</session>"))
752        .unwrap_or(&text)
753        .trim();
754    (!text.is_empty()).then(|| text.to_string())
755}
756
757fn short_session_id(id: &str) -> String {
758    let id = id.trim();
759    if id.is_empty() {
760        return "session".to_string();
761    }
762    let compact = id
763        .rsplit(['/', '\\'])
764        .next()
765        .unwrap_or(id)
766        .trim_end_matches(".jsonl");
767    const MAX_SESSION_ID_CHARS: usize = 12;
768    if compact.chars().count() <= MAX_SESSION_ID_CHARS {
769        return compact.to_string();
770    }
771    let head = compact.chars().take(6).collect::<String>();
772    let tail = compact
773        .chars()
774        .rev()
775        .take(5)
776        .collect::<Vec<_>>()
777        .into_iter()
778        .rev()
779        .collect::<String>();
780    format!("{head}.{tail}")
781}
782
783fn json_i64(value: &Value, key: &str) -> i64 {
784    value.get(key).and_then(Value::as_i64).unwrap_or(0)
785}
786
787fn json_u64(value: &Value, key: &str) -> u64 {
788    value.get(key).and_then(Value::as_u64).unwrap_or(0)
789}
790
791fn iso_ms(value: &str) -> Option<u64> {
792    chrono::DateTime::parse_from_rfc3339(value)
793        .ok()
794        .and_then(|ts| u64::try_from(ts.timestamp_millis()).ok())
795}
796
797fn system_time_ms(value: SystemTime) -> u64 {
798    value
799        .duration_since(UNIX_EPOCH)
800        .unwrap_or_default()
801        .as_millis() as u64
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807    use serde_json::json;
808
809    #[test]
810    fn local_session_ids_keep_distinct_conversation_id() {
811        assert_eq!(
812            local_session_ids(&json!({"sessionId": "run", "conversation_id": "conv"})),
813            (Some("run".to_string()), Some("conv".to_string()))
814        );
815        assert_eq!(
816            local_session_ids(&json!({"payload": {"thread_id": "thread"}})),
817            (Some("thread".to_string()), Some("thread".to_string()))
818        );
819        assert_eq!(
820            local_session_ids(&json!({"payload": {"model": "gpt"}})),
821            (None, None)
822        );
823    }
824}