//! Dialogue extraction tool handlers //! //! Extracts dialogue content from spawned agent JSONL outputs for scoring. use serde::Serialize; use serde_json::Value; use std::fs::{self, File}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::Command; use crate::error::ServerError; /// Extraction status #[derive(Debug, Serialize)] #[serde(rename_all = "snake_case")] pub enum ExtractionStatus { Complete, Truncated, PartialError, } /// Extraction result #[derive(Debug, Serialize)] pub struct ExtractionResult { pub text: String, pub status: ExtractionStatus, pub source_file: String, pub message_count: usize, #[serde(skip_serializing_if = "Option::is_none")] pub errors: Option>, } /// Handle blue_extract_dialogue pub fn handle_extract_dialogue(args: &Value) -> Result { let task_id = args.get("task_id").and_then(|v| v.as_str()); let file_path_arg = args.get("file_path").and_then(|v| v.as_str()); // Resolve file path let file_path = match (task_id, file_path_arg) { (Some(id), _) => resolve_task_output(id)?, (None, Some(path)) => PathBuf::from(path), (None, None) => { return Err(ServerError::InvalidParams); } }; // Verify file exists if !file_path.exists() { return Err(ServerError::CommandFailed(format!( "JSONL file not found: {}", file_path.display() ))); } // Try jq first, fall back to pure Rust let result = if jq_available() { extract_with_jq(&file_path)? } else { extract_with_rust(&file_path)? }; let hint = match result.status { ExtractionStatus::Complete => format!( "Extracted {} assistant message(s) from {}", result.message_count, file_path.file_name().unwrap_or_default().to_string_lossy() ), ExtractionStatus::Truncated => format!( "Extracted {} assistant message(s), output truncated", result.message_count ), ExtractionStatus::PartialError => format!( "Extracted {} message(s) with {} error(s)", result.message_count, result.errors.as_ref().map(|e| e.len()).unwrap_or(0) ), }; Ok(serde_json::json!({ "status": "success", "message": blue_core::voice::info( &format!("Extracted {} messages", result.message_count), Some(&hint) ), "text": result.text, "extraction_status": format!("{:?}", result.status).to_lowercase(), "source_file": result.source_file, "message_count": result.message_count, "errors": result.errors })) } /// Resolve file path from task_id fn resolve_task_output(task_id: &str) -> Result { // Look for task output symlink in /tmp/claude/.../tasks/ let tmp_claude = PathBuf::from("/tmp/claude"); if !tmp_claude.exists() { return Err(ServerError::CommandFailed( "No /tmp/claude directory found. Is Claude Code running?".to_string(), )); } // Search for task output file for entry in fs::read_dir(&tmp_claude) .map_err(|e| ServerError::CommandFailed(format!("Failed to read /tmp/claude: {}", e)))? { let entry = entry.map_err(|e| { ServerError::CommandFailed(format!("Failed to read directory entry: {}", e)) })?; let tasks_dir = entry.path().join("tasks"); if tasks_dir.exists() { let output_file = tasks_dir.join(format!("{}.output", task_id)); if output_file.exists() { // Follow symlink to get actual file let resolved = fs::read_link(&output_file).unwrap_or(output_file.clone()); return Ok(resolved); } } } Err(ServerError::CommandFailed(format!( "Task output not found for task_id: {}", task_id ))) } /// Check if jq is available fn jq_available() -> bool { Command::new("jq") .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false) } /// Extract dialogue using jq (faster for large files) fn extract_with_jq(file_path: &Path) -> Result { let output = Command::new("jq") .arg("-r") .arg(r#"select(.type == "assistant") | .message.content[]? | select(.type == "text") | .text"#) .arg(file_path) .output() .map_err(|e| ServerError::CommandFailed(format!("Failed to run jq: {}", e)))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(ServerError::CommandFailed(format!("jq failed: {}", stderr))); } let text = String::from_utf8_lossy(&output.stdout).to_string(); // Count messages by counting non-empty segments let message_count = text.split("\n\n").filter(|s| !s.trim().is_empty()).count(); // Check for truncation (arbitrary limit: 500KB) let status = if text.len() > 500_000 { ExtractionStatus::Truncated } else { ExtractionStatus::Complete }; Ok(ExtractionResult { text, status, source_file: file_path.to_string_lossy().to_string(), message_count, errors: None, }) } /// Extract dialogue using pure Rust (fallback) fn extract_with_rust(file_path: &Path) -> Result { let file = File::open(file_path) .map_err(|e| ServerError::CommandFailed(format!("Failed to open file: {}", e)))?; let reader = BufReader::new(file); let mut texts = Vec::new(); let mut errors = Vec::new(); let mut message_count = 0; for (line_num, line_result) in reader.lines().enumerate() { let line = match line_result { Ok(l) => l, Err(e) => { errors.push(format!("Line {}: read error: {}", line_num + 1, e)); continue; } }; if line.trim().is_empty() { continue; } // Parse JSON line let json_value: Value = match serde_json::from_str(&line) { Ok(v) => v, Err(e) => { errors.push(format!("Line {}: JSON parse error: {}", line_num + 1, e)); continue; } }; // Check if this is an assistant message if json_value.get("type").and_then(|v| v.as_str()) != Some("assistant") { continue; } // Extract text content from message.content array if let Some(content_array) = json_value .get("message") .and_then(|m| m.get("content")) .and_then(|c| c.as_array()) { for content_item in content_array { if content_item.get("type").and_then(|v| v.as_str()) == Some("text") { if let Some(text) = content_item.get("text").and_then(|t| t.as_str()) { texts.push(text.to_string()); message_count += 1; } } } } } let text = texts.join("\n\n"); // Determine status let status = if !errors.is_empty() { ExtractionStatus::PartialError } else if text.len() > 500_000 { ExtractionStatus::Truncated } else { ExtractionStatus::Complete }; Ok(ExtractionResult { text, status, source_file: file_path.to_string_lossy().to_string(), message_count, errors: if errors.is_empty() { None } else { Some(errors) }, }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_jq_check() { // Just verify this doesn't panic let _ = jq_available(); } }