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