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 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
873fn 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 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}