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 ¶ms.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}