Add 3 tools: - blue_dialogue_lint: Validate dialogue markdown against pattern - blue_extract_dialogue: Extract dialogue from agent JSONL outputs - blue_playwright_verify: Generate Playwright verification plans Features: - Weighted scoring for dialogue linting (Critical/Major/Minor) - jq fallback to pure Rust for JSONL extraction - URL safety classification (localhost/dev/staging/production) Total: 50 tools ported from coherence-mcp Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
259 lines
7.7 KiB
Rust
259 lines
7.7 KiB
Rust
//! 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<Vec<String>>,
|
|
}
|
|
|
|
/// Handle blue_extract_dialogue
|
|
pub fn handle_extract_dialogue(args: &Value) -> Result<Value, ServerError> {
|
|
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<PathBuf, ServerError> {
|
|
// 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<ExtractionResult, ServerError> {
|
|
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<ExtractionResult, ServerError> {
|
|
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();
|
|
}
|
|
}
|