Skip to main content

atheneum/graph/
claude.rs

1use std::fs::{self, File};
2use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
3use std::path::{Path, PathBuf};
4
5use anyhow::Result;
6use chrono::DateTime;
7use serde_json::{json, Value};
8use sha2::{Digest, Sha256};
9
10use super::{
11    AtheneumGraph, ClaudeTranscriptImportParams, ClaudeTranscriptImportSummary, FileAccessParams,
12    PromptParams, RecordEventParams, SessionParams, SessionProgressParams, ToolCallParams,
13};
14
15const MAX_LINE_BYTES: usize = 10 * 1024 * 1024;
16const TRANSCRIPT_SOURCE: &str = "claude_transcript";
17
18#[derive(Debug, Clone, Copy)]
19struct TranscriptCursor {
20    offset: u64,
21    prompt_sequence: i64,
22    tool_sequence: i64,
23    file_access_sequence: i64,
24    file_write_sequence: i64,
25    inode: u64,
26    mtime_ns: u64,
27}
28
29#[derive(Debug, Default)]
30struct TranscriptSummary {
31    model: Option<String>,
32    git_branch: Option<String>,
33    total_input_tokens: i64,
34    total_output_tokens: i64,
35    total_cache_read_tokens: i64,
36    total_cache_create_tokens: i64,
37    prompt_count: i64,
38    tool_call_count: i64,
39    file_access_count: i64,
40    file_write_count: i64,
41    compaction_count: i64,
42    last_context_tokens: i64,
43    prev_cache_read_tokens: i64,
44}
45
46#[derive(Debug, Default)]
47struct DeltaImport {
48    imported_prompts: i64,
49    imported_tool_calls: i64,
50    imported_file_accesses: i64,
51    imported_file_writes: i64,
52    offset: u64,
53    prompt_sequence: i64,
54    tool_sequence: i64,
55    file_access_sequence: i64,
56    file_write_sequence: i64,
57}
58
59impl AtheneumGraph {
60    pub fn sync_claude_transcript(
61        &self,
62        params: ClaudeTranscriptImportParams,
63    ) -> Result<ClaudeTranscriptImportSummary> {
64        let transcript_path = params.transcript_path;
65        let transcript_path = transcript_path.canonicalize().unwrap_or(transcript_path);
66        let session_id = params
67            .session_id
68            .clone()
69            .unwrap_or_else(|| transcript_stem(&transcript_path));
70        let project = params
71            .project
72            .clone()
73            .unwrap_or_else(|| infer_project_id(&transcript_path));
74        let cursor_key = format!("claude:{}:{}", session_id, transcript_path.display());
75        let identity = file_identity(&transcript_path)?;
76        let metadata = fs::metadata(&transcript_path)
77            .map_err(|e| anyhow::anyhow!("stat {} failed: {}", transcript_path.display(), e))?;
78        let file_len = metadata.len();
79        let cursor = self.load_transcript_cursor(&cursor_key)?;
80        let mut reset_reason = None;
81        if let Some(existing) = cursor {
82            if file_len < existing.offset {
83                reset_reason = Some(format!(
84                    "shrank from {} to {} bytes",
85                    existing.offset, file_len
86                ));
87            }
88            if reset_reason.is_none()
89                && existing.mtime_ns != 0
90                && existing.mtime_ns != identity.1
91                && file_len == existing.offset
92            {
93                reset_reason = Some("rewritten in place with identical length".to_string());
94            }
95            if existing.inode != 0 && existing.inode != identity.0 && existing.offset > 0 {
96                reset_reason = Some(format!(
97                    "inode changed from {} to {}",
98                    existing.inode, identity.0
99                ));
100            }
101        }
102        let cursor = if let Some(reason) = reset_reason {
103            self.reset_claude_transcript_import(&cursor_key, &session_id, &reason)?;
104            None
105        } else {
106            cursor
107        };
108
109        let full_summary = scan_transcript_summary(&transcript_path)?;
110        self.record_session(SessionParams {
111            session_id: session_id.clone(),
112            agent_name: params.agent_name.clone(),
113            project: project.clone(),
114            tool: params.tool.clone(),
115            trigger: params.trigger.clone(),
116            model: full_summary.model.clone(),
117            git_branch: full_summary.git_branch.clone(),
118            git_head: None,
119            parent_session_id: None,
120            relations: vec![],
121        })?;
122
123        let delta = import_transcript_delta(
124            self,
125            &transcript_path,
126            &session_id,
127            cursor.unwrap_or(TranscriptCursor {
128                offset: 0,
129                prompt_sequence: 0,
130                tool_sequence: 0,
131                file_access_sequence: 0,
132                file_write_sequence: 0,
133                inode: identity.0,
134                mtime_ns: identity.1,
135            }),
136        )?;
137
138        self.update_session_progress(SessionProgressParams {
139            session_id: session_id.clone(),
140            model: full_summary.model.clone(),
141            git_branch: full_summary.git_branch.clone(),
142            prompt_count: full_summary.prompt_count,
143            tool_call_count: full_summary.tool_call_count,
144            file_write_count: full_summary.file_write_count,
145            total_input_tokens: full_summary.total_input_tokens,
146            total_output_tokens: full_summary.total_output_tokens,
147            total_cost_usd: 0.0,
148        })?;
149
150        self.store_transcript_cursor(
151            &cursor_key,
152            &session_id,
153            &params.tool,
154            &transcript_path,
155            delta.offset,
156            delta.prompt_sequence,
157            delta.tool_sequence,
158            delta.file_access_sequence,
159            delta.file_write_sequence,
160            identity,
161        )?;
162
163        if delta.imported_prompts > 0
164            || delta.imported_tool_calls > 0
165            || delta.imported_file_accesses > 0
166            || delta.imported_file_writes > 0
167        {
168            self.record_event(RecordEventParams {
169                event_type: "transcript_sync".to_string(),
170                entity_id: cursor_key.clone(),
171                session_id: session_id.clone(),
172                payload: json!({
173                    "tool": params.tool,
174                    "source": TRANSCRIPT_SOURCE,
175                    "transcript_path": transcript_path,
176                    "project": project,
177                    "imported_offset": delta.offset,
178                    "imported_prompts": delta.imported_prompts,
179                    "imported_tool_calls": delta.imported_tool_calls,
180                    "imported_file_accesses": delta.imported_file_accesses,
181                    "imported_file_writes": delta.imported_file_writes,
182                    "total_input_tokens": full_summary.total_input_tokens,
183                    "total_output_tokens": full_summary.total_output_tokens,
184                    "total_cache_read_tokens": full_summary.total_cache_read_tokens,
185                    "total_cache_create_tokens": full_summary.total_cache_create_tokens,
186                    "compaction_count": full_summary.compaction_count,
187                }),
188                relations: vec![],
189            })?;
190        }
191
192        Ok(ClaudeTranscriptImportSummary {
193            session_id,
194            project,
195            model: full_summary.model,
196            git_branch: full_summary.git_branch,
197            total_input_tokens: full_summary.total_input_tokens,
198            total_output_tokens: full_summary.total_output_tokens,
199            total_cache_read_tokens: full_summary.total_cache_read_tokens,
200            total_cache_create_tokens: full_summary.total_cache_create_tokens,
201            prompt_count: full_summary.prompt_count,
202            tool_call_count: full_summary.tool_call_count,
203            file_access_count: full_summary.file_access_count,
204            file_write_count: full_summary.file_write_count,
205            compaction_count: full_summary.compaction_count,
206            imported_prompts: delta.imported_prompts,
207            imported_tool_calls: delta.imported_tool_calls,
208            imported_file_accesses: delta.imported_file_accesses,
209            imported_file_writes: delta.imported_file_writes,
210            imported_offset: delta.offset,
211        })
212    }
213
214    fn load_transcript_cursor(&self, source_key: &str) -> Result<Option<TranscriptCursor>> {
215        let source_key = source_key.to_string();
216        self.with_raw_connection(|conn| {
217            Ok(conn
218                .query_row(
219                    "SELECT offset, prompt_sequence, tool_sequence, file_access_sequence,
220                            file_write_sequence, COALESCE(file_inode, 0), COALESCE(file_mtime_ns, 0)
221                     FROM transcript_imports WHERE source_key = ?1",
222                    rusqlite::params![source_key],
223                    |row| {
224                        Ok(TranscriptCursor {
225                            offset: row.get::<_, i64>(0)? as u64,
226                            prompt_sequence: row.get(1)?,
227                            tool_sequence: row.get(2)?,
228                            file_access_sequence: row.get(3)?,
229                            file_write_sequence: row.get(4)?,
230                            inode: row.get::<_, i64>(5)? as u64,
231                            mtime_ns: row.get::<_, i64>(6)? as u64,
232                        })
233                    },
234                )
235                .ok())
236        })
237    }
238
239    fn reset_claude_transcript_import(
240        &self,
241        source_key: &str,
242        session_id: &str,
243        reason: &str,
244    ) -> Result<()> {
245        let source_key = source_key.to_string();
246        let session_id_owned = session_id.to_string();
247        let session_entity_id = self.maybe_session_entity_id(session_id)?;
248        self.with_raw_connection(|conn| {
249            conn.execute(
250                "DELETE FROM transcript_imports WHERE source_key = ?1",
251                rusqlite::params![source_key],
252            )?;
253            conn.execute(
254                "DELETE FROM event_log
255                 WHERE session_id = ?1
256                   AND event_type IN ('prompt', 'tool_call', 'file_access', 'transcript_sync', 'transcript_reset')
257                   AND json_extract(payload, '$.source') = ?2",
258                rusqlite::params![session_id_owned, TRANSCRIPT_SOURCE],
259            )?;
260            conn.execute(
261                "DELETE FROM tool_calls
262                 WHERE session_id = ?1
263                   AND json_extract(args, '$.source') = ?2",
264                rusqlite::params![session_id, TRANSCRIPT_SOURCE],
265            )?;
266            conn.execute(
267                "DELETE FROM reasoning_logs
268                 WHERE session_id = ?1
269                   AND json_extract(metadata, '$.source') = ?2",
270                rusqlite::params![session_id, TRANSCRIPT_SOURCE],
271            )?;
272            Ok::<(), anyhow::Error>(())
273        })?;
274        self.delete_transcript_graph_entities(session_entity_id)?;
275        self.record_event(RecordEventParams {
276            event_type: "transcript_reset".to_string(),
277            entity_id: source_key,
278            session_id: session_id.to_string(),
279            payload: json!({
280                "source": TRANSCRIPT_SOURCE,
281                "reason": reason,
282            }),
283            relations: vec![],
284        })?;
285        Ok(())
286    }
287
288    fn delete_transcript_graph_entities(&self, session_entity_id: Option<i64>) -> Result<()> {
289        let prompt_ids = self.entity_ids_by_kind_and_source("ReasoningLog", TRANSCRIPT_SOURCE)?;
290        let tool_ids = self.entity_ids_by_kind_and_source("ToolCall", TRANSCRIPT_SOURCE)?;
291        self.delete_graph_entities(&prompt_ids)?;
292        self.delete_graph_entities(&tool_ids)?;
293        if let Some(session_entity_id) = session_entity_id {
294            self.delete_transcript_edges_for_session(session_entity_id)?;
295        }
296        Ok(())
297    }
298
299    fn entity_ids_by_kind_and_source(&self, kind: &str, source: &str) -> Result<Vec<i64>> {
300        let kind = kind.to_string();
301        let source = source.to_string();
302        self.with_raw_connection(|conn| {
303            let mut stmt = conn.prepare_cached(
304                "SELECT id FROM graph_entities
305                 WHERE kind = ?1 AND json_extract(data, '$.source') = ?2",
306            )?;
307            let rows = stmt.query_map(rusqlite::params![kind, source], |row| row.get(0))?;
308            let mut ids = Vec::new();
309            for row in rows {
310                ids.push(row?);
311            }
312            Ok(ids)
313        })
314    }
315
316    fn delete_graph_entities(&self, entity_ids: &[i64]) -> Result<()> {
317        if entity_ids.is_empty() {
318            return Ok(());
319        }
320        self.with_raw_connection(|conn| {
321            let tx = conn.unchecked_transaction()?;
322            for entity_id in entity_ids {
323                tx.execute(
324                    "DELETE FROM graph_edges WHERE from_id = ?1 OR to_id = ?1",
325                    rusqlite::params![entity_id],
326                )?;
327                tx.execute(
328                    "DELETE FROM graph_entities WHERE id = ?1",
329                    rusqlite::params![entity_id],
330                )?;
331            }
332            tx.commit()?;
333            Ok::<(), anyhow::Error>(())
334        })
335    }
336
337    fn delete_transcript_edges_for_session(&self, session_entity_id: i64) -> Result<()> {
338        self.with_raw_connection(|conn| {
339            conn.execute(
340                "DELETE FROM graph_edges
341                 WHERE edge_type IN ('accessed', 'observed_in')
342                   AND json_extract(data, '$.source') = ?1
343                   AND (from_id = ?2 OR to_id = ?2)",
344                rusqlite::params![TRANSCRIPT_SOURCE, session_entity_id],
345            )?;
346            Ok::<(), anyhow::Error>(())
347        })
348    }
349
350    #[allow(clippy::too_many_arguments)]
351    fn store_transcript_cursor(
352        &self,
353        source_key: &str,
354        session_id: &str,
355        tool: &str,
356        transcript_path: &Path,
357        offset: u64,
358        prompt_sequence: i64,
359        tool_sequence: i64,
360        file_access_sequence: i64,
361        file_write_sequence: i64,
362        identity: (u64, u64),
363    ) -> Result<()> {
364        let source_key = source_key.to_string();
365        let session_id = session_id.to_string();
366        let tool = tool.to_string();
367        let transcript_path = transcript_path.display().to_string();
368        let imported_at = chrono::Utc::now().to_rfc3339();
369        self.with_raw_connection(|conn| {
370            conn.execute(
371                "INSERT INTO transcript_imports
372                    (source_key, session_id, tool, transcript_path, offset, prompt_sequence,
373                     tool_sequence, file_access_sequence, file_write_sequence, file_inode,
374                     file_mtime_ns, imported_at)
375                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
376                 ON CONFLICT(source_key) DO UPDATE SET
377                    session_id = excluded.session_id,
378                    tool = excluded.tool,
379                    transcript_path = excluded.transcript_path,
380                    offset = excluded.offset,
381                    prompt_sequence = excluded.prompt_sequence,
382                    tool_sequence = excluded.tool_sequence,
383                    file_access_sequence = excluded.file_access_sequence,
384                    file_write_sequence = excluded.file_write_sequence,
385                    file_inode = excluded.file_inode,
386                    file_mtime_ns = excluded.file_mtime_ns,
387                    imported_at = excluded.imported_at",
388                rusqlite::params![
389                    source_key,
390                    session_id,
391                    tool,
392                    transcript_path,
393                    offset as i64,
394                    prompt_sequence,
395                    tool_sequence,
396                    file_access_sequence,
397                    file_write_sequence,
398                    identity.0 as i64,
399                    identity.1 as i64,
400                    imported_at,
401                ],
402            )?;
403            Ok::<(), anyhow::Error>(())
404        })
405    }
406}
407
408fn import_transcript_delta(
409    graph: &AtheneumGraph,
410    path: &Path,
411    session_id: &str,
412    cursor: TranscriptCursor,
413) -> Result<DeltaImport> {
414    let file =
415        File::open(path).map_err(|e| anyhow::anyhow!("open {} failed: {}", path.display(), e))?;
416    let mut reader = BufReader::new(file);
417    if cursor.offset > 0 {
418        reader
419            .seek(SeekFrom::Start(cursor.offset))
420            .map_err(|e| anyhow::anyhow!("seek {} failed: {}", path.display(), e))?;
421    }
422
423    let mut imported = DeltaImport {
424        offset: cursor.offset,
425        prompt_sequence: cursor.prompt_sequence,
426        tool_sequence: cursor.tool_sequence,
427        file_access_sequence: cursor.file_access_sequence,
428        file_write_sequence: cursor.file_write_sequence,
429        ..DeltaImport::default()
430    };
431
432    let mut line_buf = String::new();
433    loop {
434        line_buf.clear();
435        match reader
436            .by_ref()
437            .take(MAX_LINE_BYTES as u64 + 1)
438            .read_line(&mut line_buf)
439        {
440            Ok(0) => break,
441            Ok(n) => {
442                if line_buf.len() > MAX_LINE_BYTES && !line_buf.ends_with('\n') {
443                    anyhow::bail!("transcript line exceeded {} bytes", MAX_LINE_BYTES);
444                }
445                let has_newline = line_buf.ends_with('\n');
446                let line = line_buf.trim();
447                if line.is_empty() {
448                    if has_newline {
449                        imported.offset += n as u64;
450                    }
451                    continue;
452                }
453                let value = match serde_json::from_str::<Value>(line) {
454                    Ok(value) => value,
455                    Err(err) => {
456                        if has_newline {
457                            imported.offset += n as u64;
458                            continue;
459                        }
460                        return Err(anyhow::anyhow!(
461                            "incomplete JSON line at {}: {}",
462                            path.display(),
463                            err
464                        ));
465                    }
466                };
467                imported.offset += n as u64;
468                match value.get("type").and_then(Value::as_str) {
469                    Some("user") => {
470                        if is_synthetic_user_msg(&value) {
471                            continue;
472                        }
473                        let content = value
474                            .get("message")
475                            .map(extract_message_text)
476                            .unwrap_or_default();
477                        if content.is_empty() {
478                            continue;
479                        }
480                        imported.prompt_sequence += 1;
481                        graph.record_evidence_prompt(PromptParams {
482                            session_id: session_id.to_string(),
483                            role: "user".to_string(),
484                            sequence: imported.prompt_sequence,
485                            content_summary: Some(content.clone()),
486                            source: Some(TRANSCRIPT_SOURCE.to_string()),
487                            input_hash: sha256_hex(&content),
488                            input_tokens: None,
489                            output_hash: None,
490                            output_tokens: None,
491                            latency_ms: None,
492                            model: None,
493                            cost_usd: None,
494                            relations: vec![],
495                        })?;
496                        imported.imported_prompts += 1;
497                    }
498                    Some("assistant") => {
499                        let message = value.get("message").cloned().unwrap_or(Value::Null);
500                        let usage = message.get("usage");
501                        let assistant_text = extract_message_text(&message);
502                        if !assistant_text.is_empty() {
503                            imported.prompt_sequence += 1;
504                            graph.record_evidence_prompt(PromptParams {
505                                session_id: session_id.to_string(),
506                                role: "assistant".to_string(),
507                                sequence: imported.prompt_sequence,
508                                content_summary: Some(assistant_text.clone()),
509                                source: Some(TRANSCRIPT_SOURCE.to_string()),
510                                input_hash: sha256_hex(&assistant_text),
511                                input_tokens: usage
512                                    .and_then(|u| u.get("input_tokens"))
513                                    .and_then(Value::as_i64),
514                                output_hash: Some(sha256_hex(&assistant_text)),
515                                output_tokens: usage
516                                    .and_then(|u| u.get("output_tokens"))
517                                    .and_then(Value::as_i64),
518                                latency_ms: None,
519                                model: message
520                                    .get("model")
521                                    .and_then(Value::as_str)
522                                    .map(str::to_string),
523                                cost_usd: None,
524                                relations: vec![],
525                            })?;
526                            imported.imported_prompts += 1;
527                        }
528                        if let Some(content) = message.get("content").and_then(Value::as_array) {
529                            for item in content {
530                                if item.get("type").and_then(Value::as_str) != Some("tool_use") {
531                                    continue;
532                                }
533                                let tool_name = item
534                                    .get("name")
535                                    .and_then(Value::as_str)
536                                    .unwrap_or("?")
537                                    .to_string();
538                                let input = item.get("input").cloned().unwrap_or(Value::Null);
539                                imported.tool_sequence += 1;
540                                graph.record_evidence_tool_call(ToolCallParams {
541                                    session_id: session_id.to_string(),
542                                    tool_name: tool_name.clone(),
543                                    sequence: Some(imported.tool_sequence),
544                                    source: Some(TRANSCRIPT_SOURCE.to_string()),
545                                    tool_version: None,
546                                    input_hash: Some(sha256_hex(&serde_json::to_string(&input)?)),
547                                    input_summary: Some(extract_tool_summary(item)),
548                                    output_hash: None,
549                                    output_summary: None,
550                                    exit_status: "observed".to_string(),
551                                    latency_ms: 0,
552                                    input_tokens_est: None,
553                                    tool_category: "claude_transcript".to_string(),
554                                    relations: vec![],
555                                })?;
556                                imported.imported_tool_calls += 1;
557
558                                if let Some(file_path) =
559                                    input.get("file_path").and_then(Value::as_str).filter(|_| {
560                                        matches!(tool_name.as_str(), "Read" | "Edit" | "Write")
561                                    })
562                                {
563                                    imported.file_access_sequence += 1;
564                                    graph.record_evidence_file_access(FileAccessParams {
565                                        session_id: session_id.to_string(),
566                                        file_path: file_path.to_string(),
567                                        sequence: imported.file_access_sequence,
568                                        access_type: tool_name.to_lowercase(),
569                                        tool_name: Some(tool_name.clone()),
570                                        source: Some(TRANSCRIPT_SOURCE.to_string()),
571                                        relations: vec![],
572                                    })?;
573                                    imported.imported_file_accesses += 1;
574                                }
575                            }
576                        }
577                    }
578                    _ => {}
579                }
580            }
581            Err(err) => return Err(anyhow::anyhow!("read {} failed: {}", path.display(), err)),
582        }
583    }
584
585    Ok(imported)
586}
587
588fn scan_transcript_summary(path: &Path) -> Result<TranscriptSummary> {
589    let file =
590        File::open(path).map_err(|e| anyhow::anyhow!("open {} failed: {}", path.display(), e))?;
591    let mut reader = BufReader::new(file);
592    let mut summary = TranscriptSummary::default();
593    let mut line_buf = String::new();
594
595    loop {
596        line_buf.clear();
597        match reader
598            .by_ref()
599            .take(MAX_LINE_BYTES as u64 + 1)
600            .read_line(&mut line_buf)
601        {
602            Ok(0) => break,
603            Ok(_) => {
604                let line = line_buf.trim();
605                if line.is_empty() {
606                    continue;
607                }
608                let value = match serde_json::from_str::<Value>(line) {
609                    Ok(value) => value,
610                    Err(_) => continue,
611                };
612                match value.get("type").and_then(Value::as_str) {
613                    Some("user") => {
614                        if is_synthetic_user_msg(&value) {
615                            continue;
616                        }
617                        let content = value
618                            .get("message")
619                            .map(extract_message_text)
620                            .unwrap_or_default();
621                        if !content.is_empty() {
622                            summary.prompt_count += 1;
623                        }
624                        if let Some(branch) = value.get("gitBranch").and_then(Value::as_str) {
625                            summary.git_branch = Some(branch.to_string());
626                        }
627                    }
628                    Some("assistant") => {
629                        if let Some(message) = value.get("message") {
630                            if let Some(model) = message.get("model").and_then(Value::as_str) {
631                                summary.model = Some(model.to_string());
632                            }
633                            if !extract_message_text(message).is_empty() {
634                                summary.prompt_count += 1;
635                            }
636                            if let Some(usage) = message.get("usage") {
637                                let input = usage
638                                    .get("input_tokens")
639                                    .and_then(Value::as_i64)
640                                    .unwrap_or(0);
641                                let output = usage
642                                    .get("output_tokens")
643                                    .and_then(Value::as_i64)
644                                    .unwrap_or(0);
645                                let cache_read = usage
646                                    .get("cache_read_input_tokens")
647                                    .and_then(Value::as_i64)
648                                    .unwrap_or(0);
649                                let cache_create = usage
650                                    .get("cache_creation_input_tokens")
651                                    .and_then(Value::as_i64)
652                                    .unwrap_or(0);
653                                summary.total_input_tokens += input;
654                                summary.total_output_tokens += output;
655                                summary.total_cache_read_tokens += cache_read;
656                                summary.total_cache_create_tokens += cache_create;
657
658                                let current_context = if cache_read == 0 && cache_create > 0 {
659                                    input + cache_create
660                                } else {
661                                    input + cache_read
662                                };
663                                if summary.last_context_tokens > 0
664                                    && current_context < summary.last_context_tokens * 7 / 10
665                                    && summary.prev_cache_read_tokens > 1000
666                                    && cache_read < summary.prev_cache_read_tokens / 5
667                                {
668                                    summary.compaction_count += 1;
669                                }
670                                summary.last_context_tokens = current_context;
671                                summary.prev_cache_read_tokens = cache_read;
672                            }
673
674                            if let Some(content) = message.get("content").and_then(Value::as_array)
675                            {
676                                for item in content {
677                                    if item.get("type").and_then(Value::as_str) != Some("tool_use")
678                                    {
679                                        continue;
680                                    }
681                                    summary.tool_call_count += 1;
682                                    let tool_name =
683                                        item.get("name").and_then(Value::as_str).unwrap_or("");
684                                    if matches!(tool_name, "Read" | "Edit" | "Write")
685                                        && item
686                                            .get("input")
687                                            .and_then(|input| input.get("file_path"))
688                                            .and_then(Value::as_str)
689                                            .is_some()
690                                    {
691                                        summary.file_access_count += 1;
692                                    }
693                                }
694                            }
695                        }
696                    }
697                    _ => {}
698                }
699            }
700            Err(err) => return Err(anyhow::anyhow!("read {} failed: {}", path.display(), err)),
701        }
702    }
703
704    Ok(summary)
705}
706
707fn is_synthetic_user_msg(entry: &Value) -> bool {
708    if entry
709        .get("isMeta")
710        .and_then(Value::as_bool)
711        .unwrap_or(false)
712    {
713        return true;
714    }
715    let Some(message) = entry.get("message") else {
716        return false;
717    };
718    match message.get("content") {
719        Some(Value::Array(arr)) => {
720            !arr.is_empty()
721                && arr
722                    .iter()
723                    .all(|block| block.get("type").and_then(Value::as_str) == Some("tool_result"))
724        }
725        Some(Value::String(s)) => {
726            let trimmed = s.trim_start();
727            trimmed.starts_with("<local-command-stdout>")
728                || trimmed.starts_with("<local-command-stderr>")
729                || trimmed.starts_with("<local-command-caveat>")
730                || trimmed.starts_with("<command-name>")
731                || trimmed.starts_with("<bash-input>")
732                || trimmed.starts_with("<bash-stdout>")
733                || trimmed.starts_with("<bash-stderr>")
734        }
735        _ => false,
736    }
737}
738
739fn extract_message_text(message: &Value) -> String {
740    let raw = match message.get("content") {
741        Some(Value::String(s)) => s.clone(),
742        Some(Value::Array(arr)) => arr
743            .iter()
744            .filter_map(|block| {
745                if block.get("type").and_then(Value::as_str) == Some("text") {
746                    block
747                        .get("text")
748                        .and_then(Value::as_str)
749                        .map(str::to_string)
750                } else {
751                    None
752                }
753            })
754            .collect::<Vec<_>>()
755            .join(" "),
756        _ => String::new(),
757    };
758    normalize_text(&raw, 500)
759}
760
761fn extract_tool_summary(tool_use: &Value) -> String {
762    if let Some(input) = tool_use.get("input") {
763        if let Some(file_path) = input.get("file_path").and_then(Value::as_str) {
764            return shorten_path(file_path);
765        }
766        if let Some(command) = input.get("command").and_then(Value::as_str) {
767            let first_line = command.lines().next().unwrap_or(command);
768            return normalize_text(first_line, 120);
769        }
770        if let Some(pattern) = input.get("pattern").and_then(Value::as_str) {
771            return normalize_text(pattern, 120);
772        }
773    }
774    String::new()
775}
776
777fn normalize_text(raw: &str, max: usize) -> String {
778    let cleaned = raw
779        .lines()
780        .map(str::trim)
781        .filter(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with("```"))
782        .collect::<Vec<_>>()
783        .join(" ");
784    let mut text = cleaned;
785    while let Some(start) = text.find("[Image") {
786        if let Some(end) = text[start..].find(']') {
787            text = format!("{}{}", &text[..start], text[start + end + 1..].trim_start());
788        } else {
789            break;
790        }
791    }
792    truncate(&text, max)
793}
794
795fn truncate(value: &str, max: usize) -> String {
796    let mut out = String::new();
797    for ch in value.chars().take(max) {
798        out.push(ch);
799    }
800    out.trim().to_string()
801}
802
803fn shorten_path(path: &str) -> String {
804    let mut parts = path.rsplit('/');
805    match (parts.next(), parts.next()) {
806        (Some(last), Some(parent)) => format!("{}/{}", parent, last),
807        _ => path.to_string(),
808    }
809}
810
811fn transcript_stem(path: &Path) -> String {
812    path.file_stem()
813        .and_then(|stem| stem.to_str())
814        .unwrap_or("unknown-session")
815        .to_string()
816}
817
818fn infer_project_id(path: &Path) -> String {
819    path.parent()
820        .and_then(Path::file_name)
821        .and_then(|name| name.to_str())
822        .map(decode_project_dir_name)
823        .unwrap_or_else(|| "claude".to_string())
824}
825
826fn decode_project_dir_name(encoded: &str) -> String {
827    if !encoded.starts_with('-') {
828        return encoded.to_string();
829    }
830    let decoded = format!("/{}", encoded.trim_start_matches('-').replace('-', "/"));
831    PathBuf::from(decoded)
832        .file_name()
833        .and_then(|name| name.to_str())
834        .unwrap_or(encoded)
835        .to_string()
836}
837
838fn sha256_hex(input: &str) -> String {
839    let mut hasher = Sha256::new();
840    hasher.update(input.as_bytes());
841    format!("{:x}", hasher.finalize())
842}
843
844#[cfg(unix)]
845fn file_identity(path: &Path) -> Result<(u64, u64)> {
846    use std::os::unix::fs::MetadataExt;
847
848    let metadata =
849        fs::metadata(path).map_err(|e| anyhow::anyhow!("stat {} failed: {}", path.display(), e))?;
850    let mtime_ns = DateTime::from_timestamp(metadata.mtime(), metadata.mtime_nsec() as u32)
851        .map(|dt| dt.timestamp_nanos_opt().unwrap_or_default() as u64)
852        .unwrap_or_default();
853    Ok((metadata.ino(), mtime_ns))
854}
855
856#[cfg(not(unix))]
857fn file_identity(path: &Path) -> Result<(u64, u64)> {
858    let metadata =
859        fs::metadata(path).map_err(|e| anyhow::anyhow!("stat {} failed: {}", path.display(), e))?;
860    let mtime_ns = metadata
861        .modified()
862        .ok()
863        .and_then(|mtime| mtime.duration_since(std::time::UNIX_EPOCH).ok())
864        .map(|duration| duration.as_nanos() as u64)
865        .unwrap_or_default();
866    Ok((metadata.len(), mtime_ns))
867}