diff --git a/crates/cli/src/cli/chat/mod.rs b/crates/cli/src/cli/chat/mod.rs index b4bcb8af7..c15b5cffe 100644 --- a/crates/cli/src/cli/chat/mod.rs +++ b/crates/cli/src/cli/chat/mod.rs @@ -291,6 +291,8 @@ const TRUST_ALL_TEXT: &str = color_print::cstr! {"All tools are now trus const TOOL_BULLET: &str = " ● "; const CONTINUATION_LINE: &str = " ⋮ "; const PURPOSE_ARROW: &str = " ↳ "; +const SUCCESS_TICK: &str = " ✓ "; +const ERROR_EXCLAMATION: &str = " ❗ "; pub async fn launch_chat(database: &mut Database, telemetry: &TelemetryThread, args: cli::Chat) -> Result { let trust_tools = args.trust_tools.map(|mut tools| { diff --git a/crates/cli/src/cli/chat/tools/execute_bash.rs b/crates/cli/src/cli/chat/tools/execute_bash.rs index 68caa287d..ec1003869 100644 --- a/crates/cli/src/cli/chat/tools/execute_bash.rs +++ b/crates/cli/src/cli/chat/tools/execute_bash.rs @@ -26,10 +26,6 @@ use super::{ MAX_TOOL_RESPONSE_SIZE, OutputKind, }; -use crate::cli::chat::{ - CONTINUATION_LINE, - PURPOSE_ARROW, -}; use crate::platform::Context; const READONLY_COMMANDS: &[&str] = &["ls", "cat", "echo", "pwd", "which", "head", "tail", "find", "grep"]; @@ -127,21 +123,7 @@ impl ExecuteBash { )?; // Add the summary if available - if let Some(summary) = &self.summary { - queue!( - updates, - style::Print(CONTINUATION_LINE), - style::Print("\n"), - style::Print(PURPOSE_ARROW), - style::SetForegroundColor(Color::Blue), - style::Print("Purpose: "), - style::ResetColor, - style::Print(summary), - style::Print("\n"), - )?; - } - - queue!(updates, style::Print("\n"))?; + super::queue_summary(self.summary.as_deref(), updates, Some(2))?; Ok(()) } diff --git a/crates/cli/src/cli/chat/tools/fs_read.rs b/crates/cli/src/cli/chat/tools/fs_read.rs index 99a0f7f43..a9f5b8a4c 100644 --- a/crates/cli/src/cli/chat/tools/fs_read.rs +++ b/crates/cli/src/cli/chat/tools/fs_read.rs @@ -1,6 +1,11 @@ use std::collections::VecDeque; +use std::fmt::Write as FmtWrite; use std::fs::Metadata; use std::io::Write; +use std::time::{ + SystemTime, + UNIX_EPOCH, +}; use crossterm::queue; use crossterm::style::{ @@ -15,6 +20,10 @@ use serde::{ Deserialize, Serialize, }; +use sha2::{ + Digest, + Sha256, +}; use syntect::util::LinesWithEndings; use tracing::{ debug, @@ -28,6 +37,7 @@ use super::{ format_path, sanitize_path_tool_arg, }; +use crate::cli::chat::CONTINUATION_LINE; use crate::cli::chat::util::images::{ handle_images_from_paths, is_supported_image_type, @@ -36,39 +46,498 @@ use crate::cli::chat::util::images::{ use crate::platform::Context; #[derive(Debug, Clone, Deserialize)] -#[serde(tag = "mode")] +#[serde(untagged)] pub enum FsRead { + Mode(FsReadMode), + Operations(FsReadOperations), +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "mode")] +pub enum FsReadMode { Line(FsLine), Directory(FsDirectory), Search(FsSearch), Image(FsImage), } +#[derive(Debug, Clone, Deserialize)] +pub struct FsReadOperations { + pub file_reads: Vec, + pub summary: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "mode")] +pub enum FsReadOperation { + Line(FsLineOperation), + Directory(FsDirectoryOperation), + Search(FsSearchOperation), + Image(FsImage), +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FsLineOperation { + pub path: String, + pub start_line: Option, + pub end_line: Option, + pub summary: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FsDirectoryOperation { + pub path: String, + pub depth: Option, + pub summary: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FsSearchOperation { + pub path: String, + pub substring_match: String, + pub context_lines: Option, + pub summary: Option, +} + +/// Represents either a single path or multiple paths +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum PathOrPaths { + Multiple(Vec), + Single(String), +} + +impl PathOrPaths { + pub fn is_batch(&self) -> bool { + matches!(self, PathOrPaths::Multiple(_)) + } + + pub fn as_single(&self) -> Option<&str> { + if let PathOrPaths::Single(s) = self { + Some(s) + } else { + None + } + } + + pub fn as_multiple(&self) -> Option<&[String]> { + if let PathOrPaths::Multiple(v) = self { + Some(v) + } else { + None + } + } + + pub fn iter(&self) -> Box + '_> { + match self { + PathOrPaths::Single(s) => Box::new(std::iter::once(s)), + PathOrPaths::Multiple(v) => Box::new(v.iter()), + } + } +} + +// Response for a batch of file read operations +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchReadResult { + pub total_files: usize, + pub successful_reads: usize, + pub failed_reads: usize, + pub results: Vec, +} + +impl BatchReadResult { + /// Create a new BatchReadResult from a vector of FileReadResult objects + pub fn new(results: Vec) -> Self { + let successful_reads = results.iter().filter(|r| r.success).count(); + Self { + total_files: results.len(), + successful_reads, + failed_reads: results.len() - successful_reads, + results, + } + } +} + +/// Response for a single file read operation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileReadResult { + pub path: String, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_modified: Option, +} + +impl FileReadResult { + /// Create a new successful FileReadResult with content hash and last modified timestamp + pub fn success(path: String, content: String, metadata: Option<&Metadata>) -> Self { + let content_hash: Option = Some(hash_content(&content)); + let last_modified = metadata.and_then(|md| md.modified().ok().map(format_timestamp)); + + Self { + path, + success: true, + content: Some(content), + error: None, + content_hash, + last_modified, + } + } + + /// Create a new error FileReadResult + pub fn error(path: String, error: String) -> Self { + Self { + path, + success: false, + content: None, + error: Some(error), + content_hash: None, + last_modified: None, + } + } +} + +/// Helper function to read a file with specified line range +async fn read_file_with_lines( + ctx: &Context, + path_str: &str, + start_line: Option, + end_line: Option, +) -> Result { + let path = sanitize_path_tool_arg(ctx, path_str); + debug!(?path, "Reading"); + let file = ctx.fs().read_to_string(&path).await?; + let line_count = file.lines().count(); + + let start = convert_negative_index(line_count, start_line.unwrap_or(FsLine::DEFAULT_START_LINE)); + let end = convert_negative_index(line_count, end_line.unwrap_or(FsLine::DEFAULT_END_LINE)); + + // safety check to ensure end is always greater than start + let end = end.max(start); + + if start >= line_count { + bail!( + "starting index: {} is outside of the allowed range: ({}, {})", + start_line.unwrap_or(FsLine::DEFAULT_START_LINE), + -(line_count as i64), + line_count + ); + } + + // The range should be inclusive on both ends. + let file_contents = file + .lines() + .skip(start) + .take(end - start + 1) + .collect::>() + .join("\n"); + + let byte_count = file_contents.len(); + if byte_count > MAX_TOOL_RESPONSE_SIZE { + bail!( + "This tool only supports reading {MAX_TOOL_RESPONSE_SIZE} bytes at a +time. You tried to read {byte_count} bytes. Try executing with fewer lines specified." + ); + } + + Ok(file_contents) +} + +/// Helper function to read a directory with specified depth +async fn read_single_directory( + ctx: &Context, + path_str: &str, + depth: Option, + updates: &mut impl Write, +) -> Result { + let path = sanitize_path_tool_arg(ctx, path_str); + let cwd = ctx.env().current_dir()?; + let max_depth = depth.unwrap_or(FsDirectory::DEFAULT_DEPTH); + debug!(?path, max_depth, "Reading directory at path with depth"); + let mut result = Vec::new(); + let mut dir_queue = VecDeque::new(); + dir_queue.push_back((path, 0)); + while let Some((path, depth)) = dir_queue.pop_front() { + if depth > max_depth { + break; + } + let relative_path = format_path(&cwd, &path); + if !relative_path.is_empty() { + queue!( + updates, + style::Print(" Reading: "), + style::SetForegroundColor(Color::Green), + style::Print(&relative_path), + style::ResetColor, + style::Print("\n"), + )?; + } + let mut read_dir = ctx.fs().read_dir(path).await?; + + #[cfg(windows)] + while let Some(ent) = read_dir.next_entry().await? { + let md = ent.metadata().await?; + + let modified_timestamp = md.modified()?.duration_since(std::time::UNIX_EPOCH)?.as_secs(); + let datetime = time::OffsetDateTime::from_unix_timestamp(modified_timestamp as i64).unwrap(); + let formatted_date = datetime + .format(time::macros::format_description!( + "[month repr:short] [day] [hour]:[minute]" + )) + .unwrap(); + + result.push(format!( + "{} {} {} {}", + format_ftype(&md), + String::from_utf8_lossy(ent.file_name().as_encoded_bytes()), + formatted_date, + ent.path().to_string_lossy() + )); + + if md.is_dir() { + if md.is_dir() { + dir_queue.push_back((ent.path(), depth + 1)); + } + } + } + + #[cfg(unix)] + while let Some(ent) = read_dir.next_entry().await? { + use std::os::unix::fs::{ + MetadataExt, + PermissionsExt, + }; + + let md = ent.metadata().await?; + let formatted_mode = format_mode(md.permissions().mode()).into_iter().collect::(); + + let modified_timestamp = md.modified()?.duration_since(std::time::UNIX_EPOCH)?.as_secs(); + let datetime = time::OffsetDateTime::from_unix_timestamp(modified_timestamp as i64).unwrap(); + let formatted_date = datetime + .format(time::macros::format_description!( + "[month repr:short] [day] [hour]:[minute]" + )) + .unwrap(); + + // Mostly copying "The Long Format" from `man ls`. + // TODO: query user/group database to convert uid/gid to names? + result.push(format!( + "{}{} {} {} {} {} {} {}", + format_ftype(&md), + formatted_mode, + md.nlink(), + md.uid(), + md.gid(), + md.size(), + formatted_date, + ent.path().to_string_lossy() + )); + if md.is_dir() { + dir_queue.push_back((ent.path(), depth + 1)); + } + } + } + + let file_count = result.len(); + let result = result.join("\n"); + let byte_count = result.len(); + if byte_count > MAX_TOOL_RESPONSE_SIZE { + bail!( + "This tool only supports reading up to {MAX_TOOL_RESPONSE_SIZE} bytes at a time. You tried to read {byte_count} bytes ({file_count} files). Try executing with fewer lines specified." + ); + } + + Ok(result) +} + +/// Helper function to search a file with specified pattern +async fn search_single_file( + ctx: &Context, + path_str: &str, + pattern: &str, + context_lines: Option, + updates: &mut impl Write, +) -> Result { + let file_path = sanitize_path_tool_arg(ctx, path_str); + let relative_path = format_path(ctx.env().current_dir()?, &file_path); + let context_lines = context_lines.unwrap_or(FsSearch::DEFAULT_CONTEXT_LINES); + + let file_content = ctx.fs().read_to_string(&file_path).await?; + let lines: Vec<&str> = LinesWithEndings::from(&file_content).collect(); + + let mut results = Vec::new(); + let mut total_matches = 0; + + // Case insensitive search + let pattern_lower = pattern.to_lowercase(); + for (line_num, line) in lines.iter().enumerate() { + if line.to_lowercase().contains(&pattern_lower) { + total_matches += 1; + let start = line_num.saturating_sub(context_lines); + let end = lines.len().min(line_num + context_lines + 1); + let mut context_text = Vec::new(); + (start..end).for_each(|i| { + let prefix = if i == line_num { + FsSearch::MATCHING_LINE_PREFIX + } else { + FsSearch::CONTEXT_LINE_PREFIX + }; + let line_text = lines[i].to_string(); + context_text.push(format!("{}{}: {}", prefix, i + 1, line_text)); + }); + let match_text = context_text.join(""); + results.push(SearchMatch { + line_number: line_num + 1, + context: match_text, + }); + } + } + + // Format the search results summary with consistent styling + super::queue_function_result( + &format!( + "Found {} matches for pattern '{}' in {}", + total_matches, pattern, relative_path + ), + updates, + false, + false, + )?; + + Ok(serde_json::to_string(&results)?) +} + impl FsRead { pub async fn validate(&mut self, ctx: &Context) -> Result<()> { match self { - FsRead::Line(fs_line) => fs_line.validate(ctx).await, - FsRead::Directory(fs_directory) => fs_directory.validate(ctx).await, - FsRead::Search(fs_search) => fs_search.validate(ctx).await, - FsRead::Image(fs_image) => fs_image.validate(ctx).await, + FsRead::Mode(mode) => match mode { + FsReadMode::Line(fs_line) => fs_line.validate(ctx).await, + FsReadMode::Directory(fs_directory) => fs_directory.validate(ctx).await, + FsReadMode::Search(fs_search) => fs_search.validate(ctx).await, + FsReadMode::Image(fs_image) => fs_image.validate(ctx).await, + }, + // Batch validation – iterate through each op + FsRead::Operations(ops) => { + if ops.file_reads.is_empty() { + bail!("At least one operation must be specified"); + } + for op in &mut ops.file_reads { + match op { + FsReadOperation::Line(l) => validate_line(ctx, &l.path).await?, + FsReadOperation::Directory(d) => validate_dir(ctx, &d.path).await?, + FsReadOperation::Search(s) => validate_search(ctx, &s.path, &s.substring_match).await?, + FsReadOperation::Image(img) => img.validate(ctx).await?, + } + } + Ok(()) + }, } } pub async fn queue_description(&self, ctx: &Context, updates: &mut impl Write) -> Result<()> { match self { - FsRead::Line(fs_line) => fs_line.queue_description(ctx, updates).await, - FsRead::Directory(fs_directory) => fs_directory.queue_description(updates), - FsRead::Search(fs_search) => fs_search.queue_description(updates), - FsRead::Image(fs_image) => fs_image.queue_description(updates), + FsRead::Mode(mode) => match mode { + FsReadMode::Line(fs_line) => fs_line.queue_description(ctx, updates).await, + FsReadMode::Directory(fs_directory) => fs_directory.queue_description(updates), + FsReadMode::Search(fs_search) => fs_search.queue_description(updates), + FsReadMode::Image(fs_image) => fs_image.queue_description(updates), + }, + FsRead::Operations(ops) => { + super::queue_summary(ops.summary.as_deref(), updates, Some(2))?; + + for (idx, op) in ops.file_reads.iter().enumerate() { + if idx > 0 { + writeln!(updates)?; + } + if ops.file_reads.len() > 1 { + queue!(updates, style::Print(format!(" ↱ Operation {}:\n", idx + 1)))?; + } + match op { + FsReadOperation::Line(l) => queue_desc_line(ctx, l, updates).await?, + FsReadOperation::Directory(d) => queue_desc_dir(d, updates)?, + FsReadOperation::Search(s) => queue_desc_search(s, updates)?, + FsReadOperation::Image(img) => img.queue_description(updates)?, + } + } + Ok(()) + }, } } pub async fn invoke(&self, ctx: &Context, updates: &mut impl Write) -> Result { match self { - FsRead::Line(fs_line) => fs_line.invoke(ctx, updates).await, - FsRead::Directory(fs_directory) => fs_directory.invoke(ctx, updates).await, - FsRead::Search(fs_search) => fs_search.invoke(ctx, updates).await, - FsRead::Image(fs_image) => fs_image.invoke(ctx, updates).await, + FsRead::Mode(mode) => match mode { + FsReadMode::Line(fs_line) => fs_line.invoke(ctx, updates).await, + FsReadMode::Directory(fs_directory) => fs_directory.invoke(ctx, updates).await, + FsReadMode::Search(fs_search) => fs_search.invoke(ctx, updates).await, + FsReadMode::Image(fs_image) => fs_image.invoke(ctx, updates).await, + }, + FsRead::Operations(ops) => { + debug!("Executing {} operations", ops.file_reads.len()); + let mut results = Vec::with_capacity(ops.file_reads.len()); + + for op in &ops.file_reads { + match op { + FsReadOperation::Line(l) => { + let out = perform_line(ctx, l, updates).await?; + results.push(out); + }, + FsReadOperation::Directory(d) => { + let out = perform_dir(ctx, d, updates).await?; + results.push(out); + }, + FsReadOperation::Search(s) => { + let out = perform_search(ctx, s, updates).await?; + results.push(out); + }, + FsReadOperation::Image(img) => { + let result = img.invoke(ctx, updates).await?; + if let OutputKind::Images(images) = result.output { + return Ok(InvokeOutput { + output: OutputKind::Images(images), + }); + } + }, + } + } + + let batch_result = BatchReadResult::new(results); + queue!( + updates, + style::Print("\n"), + style::Print(CONTINUATION_LINE), + style::Print("\n") + )?; + + super::queue_function_result( + &format!( + "Summary: {} files processed, {} successful, {} failed", + batch_result.total_files, batch_result.successful_reads, batch_result.failed_reads + ), + updates, + false, + true, + )?; + + // If there's only one operation and it's not an image, return its content directly + if batch_result.total_files == 1 && batch_result.successful_reads == 1 { + if let Some(content) = &batch_result.results[0].content { + return Ok(InvokeOutput { + output: OutputKind::Text(content.clone()), + }); + } + } + + // For multiple operations or failed operations, return the BatchReadResult + Ok(InvokeOutput { + output: OutputKind::Text(serde_json::to_string(&batch_result)?), + }) + }, } } } @@ -77,6 +546,7 @@ impl FsRead { #[derive(Debug, Clone, Deserialize)] pub struct FsImage { pub image_paths: Vec, + pub summary: Option, } impl FsImage { @@ -110,21 +580,26 @@ impl FsImage { pub fn queue_description(&self, updates: &mut impl Write) -> Result<()> { queue!( updates, - style::Print("Reading images: \n"), + style::Print(" Reading images: \n"), style::SetForegroundColor(Color::Green), style::Print(&self.image_paths.join("\n")), style::ResetColor, )?; + + // Add the summary if available + super::queue_summary(self.summary.as_deref(), updates, None)?; + Ok(()) } } -/// Read lines from a file. +/// Read lines from a file or multiple files. #[derive(Debug, Clone, Deserialize)] pub struct FsLine { - pub path: String, + pub path: PathOrPaths, pub start_line: Option, pub end_line: Option, + pub summary: Option, } impl FsLine { @@ -132,25 +607,37 @@ impl FsLine { const DEFAULT_START_LINE: i32 = 1; pub async fn validate(&mut self, ctx: &Context) -> Result<()> { - let path = sanitize_path_tool_arg(ctx, &self.path); - if !path.exists() { - bail!("'{}' does not exist", self.path); - } - let is_file = ctx.fs().symlink_metadata(&path).await?.is_file(); - if !is_file { - bail!("'{}' is not a file", self.path); + for path_str in self.path.iter() { + validate_line(ctx, path_str).await?; } Ok(()) } pub async fn queue_description(&self, ctx: &Context, updates: &mut impl Write) -> Result<()> { - let path = sanitize_path_tool_arg(ctx, &self.path); + if self.path.is_batch() { + let paths = self.path.as_multiple().unwrap(); + queue!( + updates, + style::Print("Reading multiple files: "), + style::SetForegroundColor(Color::Green), + style::Print(format!("{} files", paths.len())), + style::ResetColor, + )?; + + // Add the summary if available + super::queue_summary(self.summary.as_deref(), updates, None)?; + + return Ok(()); + } + + let path_str = self.path.as_single().unwrap(); + let path = sanitize_path_tool_arg(ctx, path_str); let line_count = ctx.fs().read_to_string(&path).await?.lines().count(); queue!( updates, - style::Print("Reading file: "), + style::Print(" Reading file: "), style::SetForegroundColor(Color::Green), - style::Print(&self.path), + style::Print(path_str), style::ResetColor, style::Print(", "), )?; @@ -158,16 +645,18 @@ impl FsLine { let start = convert_negative_index(line_count, self.start_line()) + 1; let end = convert_negative_index(line_count, self.end_line()) + 1; match (start, end) { - _ if start == 1 && end == line_count => Ok(queue!(updates, style::Print("all lines".to_string()))?), - _ if end == line_count => Ok(queue!( + _ if start == 1 && end == line_count => { + queue!(updates, style::Print("all lines".to_string()))?; + }, + _ if end == line_count => queue!( updates, style::Print("from line "), style::SetForegroundColor(Color::Green), style::Print(start), style::ResetColor, style::Print(" to end of file"), - )?), - _ => Ok(queue!( + )?, + _ => queue!( updates, style::Print("from line "), style::SetForegroundColor(Color::Green), @@ -177,51 +666,58 @@ impl FsLine { style::SetForegroundColor(Color::Green), style::Print(end), style::ResetColor, - )?), - } + )?, + }; + + // Add the summary if available + super::queue_summary(self.summary.as_deref(), updates, None)?; + + Ok(()) } pub async fn invoke(&self, ctx: &Context, _updates: &mut impl Write) -> Result { - let path = sanitize_path_tool_arg(ctx, &self.path); - debug!(?path, "Reading"); - let file = ctx.fs().read_to_string(&path).await?; - let line_count = file.lines().count(); - let (start, end) = ( - convert_negative_index(line_count, self.start_line()), - convert_negative_index(line_count, self.end_line()), - ); - - // safety check to ensure end is always greater than start - let end = end.max(start); + // Handle batch operation + if self.path.is_batch() { + let paths = self.path.as_multiple().unwrap(); + let mut results = Vec::with_capacity(paths.len()); + + for path_str in paths { + let path = sanitize_path_tool_arg(ctx, path_str); + let result = read_file_with_lines(ctx, path_str, self.start_line, self.end_line).await; + match result { + Ok(content) => { + // Get file metadata for hash and last modified timestamp + let metadata = ctx.fs().symlink_metadata(&path).await.ok(); + results.push(FileReadResult::success(path_str.clone(), content, metadata.as_ref())); + }, + Err(err) => { + results.push(FileReadResult::error(path_str.clone(), err.to_string())); + }, + } + } - if start >= line_count { - bail!( - "starting index: {} is outside of the allowed range: ({}, {})", - self.start_line(), - -(line_count as i64), - line_count - ); + // Create a BatchReadResult from the results + let batch_result = BatchReadResult::new(results); + return Ok(InvokeOutput { + output: OutputKind::Text(serde_json::to_string(&batch_result)?), + }); } - // The range should be inclusive on both ends. - let file_contents = file - .lines() - .skip(start) - .take(end - start + 1) - .collect::>() - .join("\n"); - - let byte_count = file_contents.len(); - if byte_count > MAX_TOOL_RESPONSE_SIZE { - bail!( - "This tool only supports reading {MAX_TOOL_RESPONSE_SIZE} bytes at a -time. You tried to read {byte_count} bytes. Try executing with fewer lines specified." - ); + // Handle single file operation + let path_str = self.path.as_single().unwrap(); + match read_file_with_lines(ctx, path_str, self.start_line, self.end_line).await { + Ok(file_contents) => { + // Get file metadata for hash and last modified timestamp + let path = sanitize_path_tool_arg(ctx, path_str); + let _metadata = ctx.fs().symlink_metadata(&path).await.ok(); + + // For single file operations, return content directly for backward compatibility + Ok(InvokeOutput { + output: OutputKind::Text(file_contents), + }) + }, + Err(err) => Err(err), } - - Ok(InvokeOutput { - output: OutputKind::Text(file_contents), - }) } fn start_line(&self) -> i32 { @@ -233,12 +729,13 @@ time. You tried to read {byte_count} bytes. Try executing with fewer lines speci } } -/// Search in a file. +/// Search in a file or multiple files. #[derive(Debug, Clone, Deserialize)] pub struct FsSearch { - pub path: String, - pub pattern: String, + pub path: PathOrPaths, + pub substring_match: String, pub context_lines: Option, + pub summary: Option, } impl FsSearch { @@ -247,86 +744,108 @@ impl FsSearch { const MATCHING_LINE_PREFIX: &str = "→ "; pub async fn validate(&mut self, ctx: &Context) -> Result<()> { - let path = sanitize_path_tool_arg(ctx, &self.path); - let relative_path = format_path(ctx.env().current_dir()?, &path); - if !path.exists() { - bail!("File not found: {}", relative_path); - } - if !ctx.fs().symlink_metadata(path).await?.is_file() { - bail!("Path is not a file: {}", relative_path); - } - if self.pattern.is_empty() { - bail!("Search pattern cannot be empty"); + for path_str in self.path.iter() { + validate_search(ctx, path_str, &self.substring_match).await?; } Ok(()) } pub fn queue_description(&self, updates: &mut impl Write) -> Result<()> { + if self.path.is_batch() { + let paths = self.path.as_multiple().unwrap(); + queue!( + updates, + style::Print("Searching multiple files: "), + style::SetForegroundColor(Color::Green), + style::Print(format!("{} files", paths.len())), + style::ResetColor, + style::Print(" for pattern: "), + style::SetForegroundColor(Color::Green), + style::Print(&self.substring_match.to_lowercase()), + style::ResetColor, + style::Print("\n"), + )?; + + // Add the summary if available + super::queue_summary(self.summary.as_deref(), updates, None)?; + + return Ok(()); + } + + let path_str = self.path.as_single().unwrap(); queue!( updates, - style::Print("Searching: "), + style::Print(" Searching: "), style::SetForegroundColor(Color::Green), - style::Print(&self.path), + style::Print(path_str), style::ResetColor, style::Print(" for pattern: "), style::SetForegroundColor(Color::Green), - style::Print(&self.pattern.to_lowercase()), + style::Print(&self.substring_match.to_lowercase()), style::ResetColor, + style::Print("\n"), )?; + + // Add the summary if available + super::queue_summary(self.summary.as_deref(), updates, None)?; + Ok(()) } pub async fn invoke(&self, ctx: &Context, updates: &mut impl Write) -> Result { - let file_path = sanitize_path_tool_arg(ctx, &self.path); - let pattern = &self.pattern; - let relative_path = format_path(ctx.env().current_dir()?, &file_path); - - let file_content = ctx.fs().read_to_string(&file_path).await?; - let lines: Vec<&str> = LinesWithEndings::from(&file_content).collect(); - - let mut results = Vec::new(); - let mut total_matches = 0; - - // Case insensitive search - let pattern_lower = pattern.to_lowercase(); - for (line_num, line) in lines.iter().enumerate() { - if line.to_lowercase().contains(&pattern_lower) { - total_matches += 1; - let start = line_num.saturating_sub(self.context_lines()); - let end = lines.len().min(line_num + self.context_lines() + 1); - let mut context_text = Vec::new(); - (start..end).for_each(|i| { - let prefix = if i == line_num { - Self::MATCHING_LINE_PREFIX - } else { - Self::CONTEXT_LINE_PREFIX - }; - let line_text = lines[i].to_string(); - context_text.push(format!("{}{}: {}", prefix, i + 1, line_text)); - }); - let match_text = context_text.join(""); - results.push(SearchMatch { - line_number: line_num + 1, - context: match_text, - }); + // Handle batch operation + if self.path.is_batch() { + let paths = self.path.as_multiple().unwrap(); + let mut results = Vec::with_capacity(paths.len()); + + for path_str in paths { + let path = sanitize_path_tool_arg(ctx, path_str); + let result = search_single_file( + ctx, + path_str, + &self.substring_match, + Some(self.context_lines()), + updates, + ) + .await; + match result { + Ok(content) => { + // Get file metadata for hash and last modified timestamp + let metadata = ctx.fs().symlink_metadata(&path).await.ok(); + results.push(FileReadResult::success(path_str.clone(), content, metadata.as_ref())); + }, + Err(err) => { + results.push(FileReadResult::error(path_str.clone(), err.to_string())); + }, + } } + + // Create a BatchReadResult from the results + let batch_result = BatchReadResult::new(results); + return Ok(InvokeOutput { + output: OutputKind::Text(serde_json::to_string(&batch_result)?), + }); } - queue!( + // Handle single file operation + let path_str = self.path.as_single().unwrap(); + match search_single_file( + ctx, + path_str, + &self.substring_match, + Some(self.context_lines()), updates, - style::SetForegroundColor(Color::Yellow), - style::ResetColor, - style::Print(format!( - "Found {} matches for pattern '{}' in {}\n", - total_matches, pattern, relative_path - )), - style::Print("\n"), - style::ResetColor, - )?; - - Ok(InvokeOutput { - output: OutputKind::Text(serde_json::to_string(&results)?), - }) + ) + .await + { + Ok(search_results) => { + // For single file operations, return content directly for backward compatibility + Ok(InvokeOutput { + output: OutputKind::Text(search_results), + }) + }, + Err(err) => Err(err), + } } fn context_lines(&self) -> usize { @@ -337,142 +856,98 @@ impl FsSearch { /// List directory contents. #[derive(Debug, Clone, Deserialize)] pub struct FsDirectory { - pub path: String, + pub path: PathOrPaths, pub depth: Option, + pub summary: Option, } impl FsDirectory { const DEFAULT_DEPTH: usize = 0; pub async fn validate(&mut self, ctx: &Context) -> Result<()> { - let path = sanitize_path_tool_arg(ctx, &self.path); - let relative_path = format_path(ctx.env().current_dir()?, &path); - if !path.exists() { - bail!("Directory not found: {}", relative_path); - } - if !ctx.fs().symlink_metadata(path).await?.is_dir() { - bail!("Path is not a directory: {}", relative_path); + for path_str in self.path.iter() { + validate_dir(ctx, path_str).await?; } Ok(()) } pub fn queue_description(&self, updates: &mut impl Write) -> Result<()> { + if self.path.is_batch() { + let paths = self.path.as_multiple().unwrap(); + queue!( + updates, + style::Print("Reading multiple directories: "), + style::SetForegroundColor(Color::Green), + style::Print(format!("{} directories", paths.len())), + style::ResetColor, + style::Print(" "), + )?; + let depth = self.depth.unwrap_or_default(); + queue!(updates, style::Print(format!("with maximum depth of {}", depth)))?; + + // Add the summary if available + super::queue_summary(self.summary.as_deref(), updates, None)?; + + return Ok(()); + } + + let path_str = self.path.as_single().unwrap(); queue!( updates, - style::Print("Reading directory: "), + style::Print(" Reading directory: "), style::SetForegroundColor(Color::Green), - style::Print(&self.path), + style::Print(path_str), style::ResetColor, style::Print(" "), )?; let depth = self.depth.unwrap_or_default(); - Ok(queue!( - updates, - style::Print(format!("with maximum depth of {}", depth)) - )?) - } + queue!(updates, style::Print(format!("with maximum depth of {}", depth)))?; - pub async fn invoke(&self, ctx: &Context, updates: &mut impl Write) -> Result { - let path = sanitize_path_tool_arg(ctx, &self.path); - let cwd = ctx.env().current_dir()?; - let max_depth = self.depth(); - debug!(?path, max_depth, "Reading directory at path with depth"); - let mut result = Vec::new(); - let mut dir_queue = VecDeque::new(); - dir_queue.push_back((path, 0)); - while let Some((path, depth)) = dir_queue.pop_front() { - if depth > max_depth { - break; - } - let relative_path = format_path(&cwd, &path); - if !relative_path.is_empty() { - queue!( - updates, - style::Print("Reading: "), - style::SetForegroundColor(Color::Green), - style::Print(&relative_path), - style::ResetColor, - style::Print("\n"), - )?; - } - let mut read_dir = ctx.fs().read_dir(path).await?; - - #[cfg(windows)] - while let Some(ent) = read_dir.next_entry().await? { - let md = ent.metadata().await?; - - let modified_timestamp = md.modified()?.duration_since(std::time::UNIX_EPOCH)?.as_secs(); - let datetime = time::OffsetDateTime::from_unix_timestamp(modified_timestamp as i64).unwrap(); - let formatted_date = datetime - .format(time::macros::format_description!( - "[month repr:short] [day] [hour]:[minute]" - )) - .unwrap(); + // Add the summary if available + super::queue_summary(self.summary.as_deref(), updates, None)?; - result.push(format!( - "{} {} {} {}", - format_ftype(&md), - String::from_utf8_lossy(ent.file_name().as_encoded_bytes()), - formatted_date, - ent.path().to_string_lossy() - )); + Ok(()) + } - if md.is_dir() { - if md.is_dir() { - dir_queue.push_back((ent.path(), depth + 1)); - } + pub async fn invoke(&self, ctx: &Context, updates: &mut impl Write) -> Result { + // Handle batch operation + if self.path.is_batch() { + let paths = self.path.as_multiple().unwrap(); + let mut results = Vec::with_capacity(paths.len()); + + for path_str in paths { + let path = sanitize_path_tool_arg(ctx, path_str); + let result = read_single_directory(ctx, path_str, Some(self.depth()), updates).await; + match result { + Ok(content) => { + // Get directory metadata for last modified timestamp + let metadata = ctx.fs().symlink_metadata(&path).await.ok(); + results.push(FileReadResult::success(path_str.clone(), content, metadata.as_ref())); + }, + Err(err) => { + results.push(FileReadResult::error(path_str.clone(), err.to_string())); + }, } } - #[cfg(unix)] - while let Some(ent) = read_dir.next_entry().await? { - use std::os::unix::fs::{ - MetadataExt, - PermissionsExt, - }; - - let md = ent.metadata().await?; - let formatted_mode = format_mode(md.permissions().mode()).into_iter().collect::(); - - let modified_timestamp = md.modified()?.duration_since(std::time::UNIX_EPOCH)?.as_secs(); - let datetime = time::OffsetDateTime::from_unix_timestamp(modified_timestamp as i64).unwrap(); - let formatted_date = datetime - .format(time::macros::format_description!( - "[month repr:short] [day] [hour]:[minute]" - )) - .unwrap(); - - // Mostly copying "The Long Format" from `man ls`. - // TODO: query user/group database to convert uid/gid to names? - result.push(format!( - "{}{} {} {} {} {} {} {}", - format_ftype(&md), - formatted_mode, - md.nlink(), - md.uid(), - md.gid(), - md.size(), - formatted_date, - ent.path().to_string_lossy() - )); - if md.is_dir() { - dir_queue.push_back((ent.path(), depth + 1)); - } - } + // Create a BatchReadResult from the results + let batch_result = BatchReadResult::new(results); + return Ok(InvokeOutput { + output: OutputKind::Text(serde_json::to_string(&batch_result)?), + }); } - let file_count = result.len(); - let result = result.join("\n"); - let byte_count = result.len(); - if byte_count > MAX_TOOL_RESPONSE_SIZE { - bail!( - "This tool only supports reading up to {MAX_TOOL_RESPONSE_SIZE} bytes at a time. You tried to read {byte_count} bytes ({file_count} files). Try executing with fewer lines specified." - ); + // Handle single directory operation + let path_str = self.path.as_single().unwrap(); + match read_single_directory(ctx, path_str, Some(self.depth()), updates).await { + Ok(directory_contents) => { + // For single directory operations, return content directly for backward compatibility + Ok(InvokeOutput { + output: OutputKind::Text(directory_contents), + }) + }, + Err(err) => Err(err), } - - Ok(InvokeOutput { - output: OutputKind::Text(result), - }) } fn depth(&self) -> usize { @@ -480,6 +955,35 @@ impl FsDirectory { } } +/// Generate a SHA-256 hash of the content +fn hash_content(content: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + let result = hasher.finalize(); + + // Convert to hex string + let mut s = String::with_capacity(result.len() * 2); + for b in result { + let _ = FmtWrite::write_fmt(&mut s, format_args!("{:02x}", b)); + } + s +} + +/// Format a SystemTime as an ISO 8601 UTC timestamp +fn format_timestamp(time: SystemTime) -> String { + let duration = time.duration_since(UNIX_EPOCH).unwrap_or_default(); + let secs = duration.as_secs(); + let nanos = duration.subsec_nanos(); + + // Use time crate to format the timestamp + let datetime = time::OffsetDateTime::from_unix_timestamp(secs as i64) + .unwrap() + .replace_nanosecond(nanos) + .unwrap(); + + datetime.format(&time::format_description::well_known::Rfc3339).unwrap() +} + /// Converts negative 1-based indices to positive 0-based indices. fn convert_negative_index(line_count: usize, i: i32) -> usize { if i <= 0 { @@ -531,6 +1035,205 @@ fn format_mode(mode: u32) -> [char; 9] { res } +/// Validation helpers for read operations +async fn validate_line(ctx: &Context, path_str: &str) -> Result<()> { + let path = sanitize_path_tool_arg(ctx, path_str); + if !path.exists() { + bail!("'{}' does not exist", path_str); + } + if !ctx.fs().symlink_metadata(&path).await?.is_file() { + bail!("'{}' is not a file", path_str); + } + Ok(()) +} + +async fn validate_dir(ctx: &Context, path_str: &str) -> Result<()> { + let path = sanitize_path_tool_arg(ctx, path_str); + let rel = format_path(ctx.env().current_dir()?, &path); + if !path.exists() { + bail!("Directory not found: {}", rel); + } + if !ctx.fs().symlink_metadata(&path).await?.is_dir() { + bail!("Path is not a directory: {}", rel); + } + Ok(()) +} + +async fn validate_search(ctx: &Context, path_str: &str, substring_match: &str) -> Result<()> { + if substring_match.is_empty() { + bail!("Search pattern cannot be empty"); + } + let path = sanitize_path_tool_arg(ctx, path_str); + let rel = format_path(ctx.env().current_dir()?, &path); + if !path.exists() { + bail!("File not found: {}", rel); + } + if !ctx.fs().symlink_metadata(&path).await?.is_file() { + bail!("Path is not a file: {}", rel); + } + Ok(()) +} + +/// Queue description helpers +async fn queue_desc_line(ctx: &Context, op: &FsLineOperation, updates: &mut impl Write) -> Result<()> { + queue!( + updates, + style::Print(" Reading file: "), + style::SetForegroundColor(Color::Green), + style::Print(&op.path), + style::ResetColor, + style::Print(", ") + )?; + // Add operation-specific summary if available + super::queue_summary(op.summary.as_deref(), updates, None)?; + let path = sanitize_path_tool_arg(ctx, &op.path); + let total = ctx.fs().read_to_string(&path).await?.lines().count(); + let start = convert_negative_index(total, op.start_line.unwrap_or(FsLine::DEFAULT_START_LINE)) + 1; + let end = convert_negative_index(total, op.end_line.unwrap_or(FsLine::DEFAULT_END_LINE)) + 1; + match (start, end) { + (1, x) if x == total => queue!(updates, style::Print("all lines"))?, + (start, x) if x == total => queue!( + updates, + style::Print("from line "), + style::SetForegroundColor(Color::Green), + style::Print(start), + style::ResetColor, + style::Print(" to end of file"), + )?, + _ => queue!( + updates, + style::Print("from line "), + style::SetForegroundColor(Color::Green), + style::Print(start), + style::ResetColor, + style::Print(" to "), + style::SetForegroundColor(Color::Green), + style::Print(end), + style::ResetColor, + )?, + } + Ok(()) +} + +fn queue_desc_dir(op: &FsDirectoryOperation, updates: &mut impl Write) -> Result<()> { + queue!( + updates, + style::Print(" Reading directory: "), + style::SetForegroundColor(Color::Green), + style::Print(&op.path), + style::ResetColor, + style::Print(" with depth "), + style::SetForegroundColor(Color::Green), + style::Print(op.depth.unwrap_or(0)), + style::ResetColor + )?; + super::queue_summary(op.summary.as_deref(), updates, None)?; + Ok(()) +} + +fn queue_desc_search(op: &FsSearchOperation, updates: &mut impl Write) -> Result<()> { + queue!( + updates, + style::Print(" Searching: "), + style::SetForegroundColor(Color::Green), + style::Print(&op.path), + style::ResetColor, + style::Print(" for pattern: "), + style::SetForegroundColor(Color::Green), + style::Print(&op.substring_match.to_lowercase()), + style::ResetColor + )?; + super::queue_summary(op.summary.as_deref(), updates, None)?; + Ok(()) +} + +/// Execution helpers +async fn perform_line(ctx: &Context, op: &FsLineOperation, updates: &mut impl Write) -> Result { + match read_file_with_lines(ctx, &op.path, op.start_line, op.end_line).await { + Ok(content) => { + let metadata = ctx + .fs() + .symlink_metadata(sanitize_path_tool_arg(ctx, &op.path)) + .await + .ok(); + super::queue_function_result( + &format!("Successfully read {} bytes from {}", content.len(), &op.path), + updates, + false, + false, + )?; + Ok(FileReadResult::success(op.path.clone(), content, metadata.as_ref())) + }, + Err(e) => { + super::queue_function_result(&format!("Error reading {}: {}", &op.path, e), updates, true, false)?; + Ok(FileReadResult::error(op.path.clone(), e.to_string())) + }, + } +} + +async fn perform_dir(ctx: &Context, op: &FsDirectoryOperation, updates: &mut impl Write) -> Result { + match read_single_directory(ctx, &op.path, op.depth, updates).await { + Ok(content) => { + let metadata = ctx + .fs() + .symlink_metadata(sanitize_path_tool_arg(ctx, &op.path)) + .await + .ok(); + // Format the success message with consistent styling + super::queue_function_result( + &format!( + "Successfully read directory {} ({} entries)", + &op.path, + content.lines().count() + ), + updates, + false, + false, + )?; + Ok(FileReadResult::success(op.path.clone(), content, metadata.as_ref())) + }, + Err(e) => { + // Format the error message with consistent styling + super::queue_function_result( + &format!("Error reading directory {}: {}", &op.path, e), + updates, + true, + false, + )?; + Ok(FileReadResult::error(op.path.clone(), e.to_string())) + }, + } +} + +async fn perform_search(ctx: &Context, op: &FsSearchOperation, updates: &mut impl Write) -> Result { + match search_single_file(ctx, &op.path, &op.substring_match, op.context_lines, updates).await { + Ok(content) => { + let metadata = ctx + .fs() + .symlink_metadata(sanitize_path_tool_arg(ctx, &op.path)) + .await + .ok(); + let matches: Vec = serde_json::from_str(&content).unwrap_or_default(); + super::queue_function_result( + &format!( + "Found {} matches for '{}' in {}", + matches.len(), + op.substring_match, + &op.path + ), + updates, + false, + false, + )?; + Ok(FileReadResult::success(op.path.clone(), content, metadata.as_ref())) + }, + Err(e) => { + super::queue_function_result(&format!("Error searching {}: {}", &op.path, e), updates, true, false)?; + Ok(FileReadResult::error(op.path.clone(), e.to_string())) + }, + } +} + #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/crates/cli/src/cli/chat/tools/mod.rs b/crates/cli/src/cli/chat/tools/mod.rs index 6d236ec70..d058a6f2a 100644 --- a/crates/cli/src/cli/chat/tools/mod.rs +++ b/crates/cli/src/cli/chat/tools/mod.rs @@ -368,6 +368,109 @@ fn supports_truecolor(ctx: &Context) -> bool { && shell_color::get_color_support().contains(shell_color::ColorSupport::TERM24BIT) } +/// Helper function to queue a summary display with consistent styling +/// Only displays the summary if it exists (Some) +/// +/// # Parameters +/// * `summary` - Optional summary text to display +/// * `updates` - The output to write to +/// * `trailing_newlines` - Number of trailing newlines to add after the summary (defaults to 1) +pub fn queue_summary(summary: Option<&str>, updates: &mut impl Write, trailing_newlines: Option) -> Result<()> { + if let Some(summary_text) = summary { + use crossterm::queue; + use crossterm::style::{ + self, + Color, + }; + + queue!( + updates, + style::Print("\n"), + style::Print(super::CONTINUATION_LINE), + style::Print("\n"), + style::Print(super::PURPOSE_ARROW), + style::SetForegroundColor(Color::Blue), + style::Print("Purpose: "), + style::ResetColor, + style::Print(summary_text), + style::Print("\n"), + )?; + + // Add any additional trailing newlines (default to 1 if not specified) + let newlines = trailing_newlines.unwrap_or(1); + for _ in 1..newlines { + queue!(updates, style::Print("\n"))?; + } + } + + Ok(()) +} + +/// Helper function to format function results with consistent styling +/// +/// # Parameters +/// * `result` - The result text to display +/// * `updates` - The output to write to +/// * `is_error` - Whether this is an error message (changes formatting) +/// * `use_bullet` - Whether to use a bullet point instead of a tick/exclamation +pub fn queue_function_result(result: &str, updates: &mut impl Write, is_error: bool, use_bullet: bool) -> Result<()> { + use crossterm::queue; + use crossterm::style::{ + self, + Color, + }; + + // Split the result into lines for proper formatting + let lines = result.lines().collect::>(); + let color = if is_error { Color::Red } else { Color::Reset }; + + queue!(updates, style::Print("\n"))?; + + // Use appropriate symbol based on parameters + if let Some(first_line) = lines.first() { + // Select symbol: bullet for summaries, tick/exclamation for operations + let symbol = if is_error { + super::ERROR_EXCLAMATION + } else if use_bullet { + super::TOOL_BULLET + } else { + super::SUCCESS_TICK + }; + + // Set color to green for success ticks + let text_color = if is_error { + Color::Red + } else if !use_bullet { + Color::Green + } else { + Color::Reset + }; + + queue!( + updates, + style::SetForegroundColor(text_color), + style::Print(symbol), + style::ResetColor, + style::Print(first_line), + style::Print("\n"), + )?; + } + + // For any additional lines, indent them properly + for line in lines.iter().skip(1) { + queue!( + updates, + style::Print(" "), // Same indentation as the bullet + style::SetForegroundColor(color), + style::Print(line), + style::ResetColor, + style::Print("\n"), + )?; + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cli/src/cli/chat/tools/tool_index.json b/crates/cli/src/cli/chat/tools/tool_index.json index 1fbe3f4d7..e18cce7a5 100644 --- a/crates/cli/src/cli/chat/tools/tool_index.json +++ b/crates/cli/src/cli/chat/tools/tool_index.json @@ -28,54 +28,178 @@ }, "fs_read": { "name": "fs_read", - "description": "Tool for reading files (for example, `cat -n`), directories (for example, `ls -la`) and images. If user has supplied paths that appear to be leading to images, you should use this tool right away using Image mode. The behavior of this tool is determined by the `mode` parameter. The available modes are:\n- line: Show lines in a file, given by an optional `start_line` and optional `end_line`.\n- directory: List directory contents. Content is returned in the \"long format\" of ls (that is, `ls -la`).\n- search: Search for a pattern in a file. The pattern is a string. The matching is case insensitive.\n\nExample Usage:\n1. Read all lines from a file: command=\"line\", path=\"/path/to/file.txt\"\n2. Read the last 5 lines from a file: command=\"line\", path=\"/path/to/file.txt\", start_line=-5\n3. List the files in the home directory: command=\"line\", path=\"~\"\n4. Recursively list files in a directory to a max depth of 2: command=\"line\", path=\"/path/to/directory\", depth=2\n5. Search for all instances of \"test\" in a file: command=\"search\", path=\"/path/to/file.txt\", pattern=\"test\"\n", + "description": "Tool for reading files, directories, and images with support for multiple operations in a single call. Each operation can have its own mode and parameters.\n\nAvailable modes for operations:\n- Line: Show lines in a file, given by an optional `start_line` and optional `end_line`\n- Directory: List directory contents in the \"long format\" of ls (that is, `ls -la`)\n- Search: Search for a substring in a file (case insensitive)\n- Image: Display images from the specified paths\n\nIf user has supplied paths that appear to be leading to images, you should use this tool right away using Image mode.\n\nPrefer batching multiple reads within a file or across files into one batch read, including optimistic reads to prevent extra roundtrips of thinking.\n\nExample Usage:\n```json\n{\n \"file_reads\": [\n {\n \"mode\": \"Line\",\n \"path\": \"/path/to/file1.txt\",\n \"start_line\": 10,\n \"end_line\": 20\n },\n {\n \"mode\": \"Search\",\n \"path\": \"/path/to/file2.txt\",\n \"substring_match\": \"important term\"\n },\n {\n \"mode\": \"Directory\",\n \"path\": \"/path/to/directory\",\n \"depth\": 1\n },\n {\n \"mode\": \"Image\",\n \"image_paths\": [\"/path/to/image1.png\", \"/path/to/image2.jpg\"]\n }\n ],\n \"summary\": \"Reading configuration files and searching for settings\"\n}\n```\n\nResponse format:\n- For a single operation, returns the content directly\n- For multiple operations, returns a BatchReadResult object with:\n - total_files: Total number of files processed\n - successful_reads: Number of successful read operations\n - failed_reads: Number of failed read operations\n - results: Array of FileReadResult objects containing:\n - path: File path\n - success: Whether the read was successful\n - content: File content (if successful)\n - error: Error message (if failed)\n - content_hash: SHA-256 hash of the content (if successful)\n - last_modified: Timestamp of when the file was last modified (if available)", "input_schema": { "type": "object", "properties": { - "path": { - "description": "Path to the file or directory. The path should be absolute, or otherwise start with ~ for the user's home.", - "type": "string" - }, - "image_paths": { - "description": "List of paths to the images. This is currently supported by the Image mode.", + "file_reads": { + "description": "Array of file read operations to perform in a single call.", "type": "array", + "minItems": 1, + "maxItems": 64, "items": { - "type": "string" + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "Line", + "Directory", + "Search", + "Image" + ], + "description": "The mode for this operation: `Line`, `Directory`, `Search`, or `Image`." + }, + "path": { + "description": "Path to the file or directory for Line, Directory, and Search modes.", + "type": "string" + }, + "image_paths": { + "description": "List of paths to the images. Only valid for Image mode.", + "type": "array", + "minItems": 1, + "maxItems": 10, + "items": { + "type": "string" + } + }, + "start_line": { + "type": "integer", + "description": "Starting line number for Line mode (1-based indexing). Required for Line mode. Default is 1, which means start from the first line.", + "default": 1 + }, + "end_line": { + "type": "integer", + "description": "Ending line number for Line mode. Use -1 for the last line of the file. Negative numbers count from the end of the file (-2 = second-to-last line, etc.). Required for Line mode. Default is -1, which means read to the end of the file.", + "default": -1 + }, + "substring_match": { + "type": "string", + "description": "Text to search for in Search mode. The search is case-insensitive and matches any occurrence of the text within lines. Does not support wildcards or regular expressions." + }, + "context_lines": { + "type": "integer", + "description": "Number of context lines to show around search results in Search mode. Default is 2.", + "default": 2 + }, + "depth": { + "type": "integer", + "description": "Depth of a recursive directory listing in Directory mode. 0 means no recursion, only list the immediate contents. Default is 0.", + "default": 0 + }, + "summary": { + "type": "string", + "description": "A brief explanation of the purpose of this specific file read operation." + } + }, + "required": [ + "mode" + ], + "allOf": [ + { + "if": { + "properties": { + "mode": { + "enum": [ + "Line" + ] + } + } + }, + "then": { + "required": [ + "path" + ], + "not": { + "required": [ + "image_paths", + "substring_match", + "context_lines" + ] + } + } + }, + { + "if": { + "properties": { + "mode": { + "enum": [ + "Directory" + ] + } + } + }, + "then": { + "required": [ + "path" + ], + "not": { + "required": [ + "image_paths", + "start_line", + "end_line", + "substring_match", + "context_lines" + ] + } + } + }, + { + "if": { + "properties": { + "mode": { + "enum": [ + "Search" + ] + } + } + }, + "then": { + "required": [ + "path", + "substring_match" + ], + "not": { + "required": [ + "image_paths", + "start_line", + "end_line" + ] + } + } + }, + { + "if": { + "properties": { + "mode": { + "enum": [ + "Image" + ] + } + } + }, + "then": { + "required": [ + "image_paths" + ], + "not": { + "required": [ + "path", + "start_line", + "end_line", + "substring_match", + "context_lines", + "depth" + ] + } + } + } + ] } }, - "mode": { - "type": "string", - "enum": [ - "Line", - "Directory", - "Search", - "Image" - ], - "description": "The mode to run in: `Line`, `Directory`, `Search`. `Line` and `Search` are only for text files, and `Directory` is only for directories. `Image` is for image files, in this mode `image_paths` is required." - }, - "start_line": { - "type": "integer", - "description": "Starting line number (optional, for Line mode). A negative index represents a line number starting from the end of the file.", - "default": 1 - }, - "end_line": { - "type": "integer", - "description": "Ending line number (optional, for Line mode). A negative index represents a line number starting from the end of the file.", - "default": -1 - }, - "pattern": { + "summary": { "type": "string", - "description": "Pattern to search for (required, for Search mode). Case insensitive. The pattern matching is performed per line." - }, - "context_lines": { - "type": "integer", - "description": "Number of context lines around search results (optional, for Search mode)", - "default": 2 - }, - "depth": { - "type": "integer", - "description": "Depth of a recursive directory listing (optional, for Directory mode)", - "default": 0 + "description": "Recommended: A brief explanation of the overall purpose of this file read operation." } }, "required": ["path", "mode"]