From 003345edc0133fbecc8bb5f95d96765fff7192c1 Mon Sep 17 00:00:00 2001 From: Tomi Eckert Date: Fri, 6 Mar 2026 01:14:56 +0100 Subject: [PATCH] feat: consolidate file write, move, grep, and delete operations into unified tools and update context compaction heuristics --- ContextCompactor.cs | 4 +- ToolRegistry.cs | 16 ++- Tools/DirTools.cs | 21 ---- Tools/EditTools.cs | 196 +++++++++++++++++----------------- Tools/FileTools.cs | 215 ++++++++++++++++++++------------------ docs/ToolConsolidation.md | 39 +++---- 6 files changed, 230 insertions(+), 261 deletions(-) diff --git a/ContextCompactor.cs b/ContextCompactor.cs index a325a7f..6e02bb1 100644 --- a/ContextCompactor.cs +++ b/ContextCompactor.cs @@ -71,10 +71,10 @@ internal sealed partial class ContextCompactor(IChatClient client) string reason = ""; // Rule 1: Deduplication. If we have already seen this file in a newer message (since we are walking backward), redact this one. - if (filesRead.TryGetValue(filePath, out int count) && count >= 3) + if (filesRead.TryGetValue(filePath, out int count) && count >= 5) { shouldRedact = true; - reason = "deduplication — you read this file 3 or more times later"; + reason = "deduplication — you read this file 5 or more times later"; } // Rule 2: TTL. If this was read 2 or more user turns ago, redact it. else if (userTurnsSeen >= 2) diff --git a/ToolRegistry.cs b/ToolRegistry.cs index 87513ec..cebda52 100644 --- a/ToolRegistry.cs +++ b/ToolRegistry.cs @@ -12,22 +12,18 @@ internal static class ToolRegistry return new List { AIFunctionFactory.Create(FileTools.ReadFile, serializerOptions: jsonOptions), - AIFunctionFactory.Create(FileTools.GrepFile, serializerOptions: jsonOptions), + AIFunctionFactory.Create(FileTools.Grep, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.ListDir, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.ReplaceLines, serializerOptions: jsonOptions), - AIFunctionFactory.Create(EditTools.InsertAfter, serializerOptions: jsonOptions), AIFunctionFactory.Create(EditTools.DeleteRange, serializerOptions: jsonOptions), - AIFunctionFactory.Create(EditTools.CreateFile, serializerOptions: jsonOptions), - AIFunctionFactory.Create(EditTools.DeleteFile, serializerOptions: jsonOptions), - AIFunctionFactory.Create(EditTools.MoveFile, serializerOptions: jsonOptions), - AIFunctionFactory.Create(DirTools.CreateDir, serializerOptions: jsonOptions), - AIFunctionFactory.Create(DirTools.RenameDir, serializerOptions: jsonOptions), - AIFunctionFactory.Create(DirTools.DeleteDir, serializerOptions: jsonOptions), + AIFunctionFactory.Create(EditTools.Delete, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.FindFiles, serializerOptions: jsonOptions), - AIFunctionFactory.Create(FileTools.GrepRecursive, serializerOptions: jsonOptions), AIFunctionFactory.Create(FileTools.GetFileInfo, serializerOptions: jsonOptions), - AIFunctionFactory.Create(EditTools.AppendToFile, serializerOptions: jsonOptions), + AIFunctionFactory.Create(EditTools.WriteToFile, serializerOptions: jsonOptions), AIFunctionFactory.Create(CommandTool.ExecuteCommand, serializerOptions: jsonOptions), + AIFunctionFactory.Create(EditTools.MoveFile, serializerOptions: jsonOptions), + AIFunctionFactory.Create(DirTools.RenameDir, serializerOptions: jsonOptions), + AIFunctionFactory.Create(DirTools.CreateDir, serializerOptions: jsonOptions), }; } } diff --git a/Tools/DirTools.cs b/Tools/DirTools.cs index 3d7816f..6350142 100644 --- a/Tools/DirTools.cs +++ b/Tools/DirTools.cs @@ -39,27 +39,6 @@ internal static class DirTools } } - [Description("Delete a directory and all its contents permanently.")] - public static string DeleteDir( - [Description("Path to the directory to delete.")] string path, - [Description("If true, delete recursively. Defaults to true.")] bool recursive = true) - { - path = ResolvePath(path); - Log($"Deleting directory: {path}"); - - if (!Directory.Exists(path)) - return $"ERROR: Directory not found: {path}"; - - try - { - Directory.Delete(path, recursive); - return $"OK: Directory deleted: '{path}'"; - } - catch (Exception ex) - { - return $"ERROR deleting directory '{path}': {ex.Message}"; - } - } [Description("Create a new directory. Creates parent directories if they don't exist. Returns OK on success, or an error message if the directory already exists or creation fails.")] public static string CreateDir( [Description("Path to the directory to create.")] string path) diff --git a/Tools/EditTools.cs b/Tools/EditTools.cs index 1dfea3e..c164be5 100644 --- a/Tools/EditTools.cs +++ b/Tools/EditTools.cs @@ -90,44 +90,6 @@ internal static partial class EditTools } } - [Description("Insert lines after the specified line:hash anchor.")] - public static string InsertAfter( - [Description("Path to the file.")] string path, - [Description("line:hash anchor to insert after (e.g. '3:0e').")] string anchor, - [Description("Raw source code to insert. Do NOT include 'lineNumber:hash|' prefixes.")] string[] newLines) - { - newLines = SanitizeNewLines(newLines); - path = FileTools.ResolvePath(path); - Log($"INSERT_AFTER: {path}"); - Log($" Anchor: {anchor}"); - Log($" Inserting {newLines.Length} lines after line {anchor.Split(':')[0]}"); - - - if (!File.Exists(path)) - return $"ERROR: File not found: {path}"; - - try - { - string[] lines = File.ReadAllLines(path); - - if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error)) - return $"ERROR: {error}"; - - - - var result = new List(lines.Length + newLines.Length); - result.AddRange(lines[..(idx + 1)]); - result.AddRange(newLines); - result.AddRange(lines[(idx + 1)..]); - - File.WriteAllLines(path, result); - return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}"; - } - catch (Exception ex) - { - return $"ERROR modifying '{path}': {ex.Message}"; - } - } [Description("Delete a range of lines.")] public static string DeleteRange( @@ -162,56 +124,46 @@ internal static partial class EditTools } } - [Description("Create a new file (parents auto-created). Max initial lines: 200. Alternatively, append lines later.")] - public static string CreateFile( - [Description("Path to the new file to create.")] string path, - [Description("Optional initial raw source code. Do NOT include 'lineNumber:hash|' prefixes.")] string[]? initialLines = null) + + + [Description("Delete a file or directory. Use mode='file' to delete a file, mode='dir' to delete a directory.")] + public static string Delete( + [Description("Path to the file or directory to delete.")] string path, + [Description("Type of deletion: 'file' or 'dir'. Defaults to 'file'.")] string mode = "file") { path = FileTools.ResolvePath(path); - Log($"Creating file: {path}"); + string targetType = mode.ToLower() == "dir" ? "directory" : "file"; + Log($"Deleting {targetType}: {path}"); - if (File.Exists(path)) - return $"ERROR: File already exists: {path}"; - - try + if (mode.ToLower() == "dir") { - if (initialLines is not null) - initialLines = SanitizeNewLines(initialLines); - string? dir = Path.GetDirectoryName(path); - if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) - Directory.CreateDirectory(dir); + if (!Directory.Exists(path)) + return $"ERROR: Directory not found: {path}"; - if (initialLines is not null && initialLines.Length > 0) - File.WriteAllLines(path, initialLines); - else - File.WriteAllText(path, ""); - - return $"OK fp:{HashlineEncoder.FileFingerprint(initialLines ?? [])}"; + try + { + Directory.Delete(path, true); + return $"OK: Directory deleted: '{path}'"; + } + catch (Exception ex) + { + return $"ERROR deleting directory '{path}': {ex.Message}"; + } } - catch (Exception ex) + else { - return $"ERROR creating '{path}': {ex.Message}"; - } - } + if (!File.Exists(path)) + return $"ERROR: File not found: {path}"; - [Description("Delete a file permanently.")] - public static string DeleteFile( - [Description("Path to the file to delete.")] string path) - { - path = FileTools.ResolvePath(path); - Log($"Deleting file: {path}"); - - if (!File.Exists(path)) - return $"ERROR: File not found: {path}"; - - try - { - File.Delete(path); - return $"OK (deleted)"; - } - catch (Exception ex) - { - return $"ERROR deleting '{path}': {ex.Message}"; + try + { + File.Delete(path); + return $"OK (deleted)"; + } + catch (Exception ex) + { + return $"ERROR deleting '{path}': {ex.Message}"; + } } } @@ -250,15 +202,19 @@ internal static partial class EditTools } } - [Description("Append lines to EOF (auto-creating the file if missing).")] - public static string AppendToFile( - [Description("Path to the file to append to.")] string path, - [Description("Raw source code to append. Do NOT include 'lineNumber:hash|' prefixes.")] string[] lines) + + [Description("Write to a file with different modes: create, append, or insert.")] + public static string WriteToFile( + [Description("Path to the file.")] string path, + [Description("Content to write.")] string[] content, + [Description("Write mode: 'create' (error if exists), 'append' (creates if missing), 'insert' (requires anchor)")] string mode = "create", + [Description("line:hash anchor to insert after (required for mode='insert', e.g. '3:0e').")] string? anchor = null) { - lines = SanitizeNewLines(lines); + content = SanitizeNewLines(content); path = FileTools.ResolvePath(path); - Log($"Appending to file: {path}"); - Log($" Appending {lines.Length} lines"); + Log($"WRITE_TO_FILE: {path}"); + Log($" Mode: {mode}"); + Log($" Writing {content.Length} lines"); try { @@ -266,26 +222,64 @@ internal static partial class EditTools if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); - if (!File.Exists(path)) + switch (mode.ToLower()) { - File.WriteAllText(path, ""); - Log($" (created new file)"); - } + case "create": + if (File.Exists(path)) + return $"ERROR: File already exists: {path}"; - using (var writer = new System.IO.StreamWriter(path, true)) - { - foreach (var line in lines) - { - writer.WriteLine(line); - } - } + if (content.Length > 0) + File.WriteAllLines(path, content); + else + File.WriteAllText(path, ""); - string[] allLines = File.ReadAllLines(path); - return $"OK fp:{HashlineEncoder.FileFingerprint([.. allLines])}"; + return $"OK fp:{HashlineEncoder.FileFingerprint(content)}"; + + case "append": + if (!File.Exists(path)) + { + File.WriteAllText(path, ""); + Log($" (created new file)"); + } + + using (var writer = new System.IO.StreamWriter(path, true)) + { + foreach (var line in content) + { + writer.WriteLine(line); + } + } + + string[] appendedLines = File.ReadAllLines(path); + return $"OK fp:{HashlineEncoder.FileFingerprint([.. appendedLines])}"; + + case "insert": + if (!File.Exists(path)) + return $"ERROR: File not found: {path}"; + + if (string.IsNullOrEmpty(anchor)) + return "ERROR: mode='insert' requires an anchor parameter"; + + string[] lines = File.ReadAllLines(path); + + if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error)) + return $"ERROR: {error}"; + + var result = new List(lines.Length + content.Length); + result.AddRange(lines[..(idx + 1)]); + result.AddRange(content); + result.AddRange(lines[(idx + 1)..]); + + File.WriteAllLines(path, result); + return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}"; + + default: + return $"ERROR: Unknown mode '{mode}'. Valid modes: create, append, insert"; + } } catch (Exception ex) { - return $"ERROR appending to '{path}': {ex.Message}"; + return $"ERROR writing to '{path}': {ex.Message}"; } } diff --git a/Tools/FileTools.cs b/Tools/FileTools.cs index 7a7d9de..c42ca84 100644 --- a/Tools/FileTools.cs +++ b/Tools/FileTools.cs @@ -56,54 +56,6 @@ internal static class FileTools } } - [Description("Search a file for a regex pattern. Returns matches with line:hash| anchors.")] - public static string GrepFile( - [Description("Path to the file to search.")] string path, - [Description("Regex pattern.")] string pattern) - { - path = ResolvePath(path); - Log($"Searching file: {path}"); - - if (!File.Exists(path)) - return $"ERROR: File not found: {path}"; - - Regex regex; - try - { - regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); - } - catch (Exception ex) - { - return $"ERROR: Invalid regex pattern '{pattern}': {ex.Message}"; - } - - try - { - string[] lines = File.ReadAllLines(path); - var sb = new System.Text.StringBuilder(); - int matchCount = 0; - - for (int i = 0; i < lines.Length; i++) - { - if (regex.IsMatch(lines[i])) - { - int lineNumber = i + 1; - string hash = HashlineEncoder.ComputeHash(lines[i].AsSpan(), lineNumber); - sb.Append(lineNumber).Append(':').Append(hash).Append('|').AppendLine(lines[i]); - matchCount++; - } - } - - if (matchCount == 0) - return $"(no matches for '{pattern}' in {path})"; - - return sb.ToString(); - } - catch (Exception ex) - { - return $"ERROR searching '{path}': {ex.Message}"; - } - } [Description("List files and subdirectories.")] public static string ListDir( @@ -174,75 +126,130 @@ internal static class FileTools } } - [Description("Recursive regex search across all files. Returns matches with file:line:hash| format.")] - public static string GrepRecursive( - [Description("Directory to search.")] string path, + + [Description("Consolidated grep operation for single file or recursive directory search.")] + public static string Grep( + [Description("Directory to search (for recursive mode) or file path (for file mode).")] string path, [Description("Regex pattern.")] string pattern, - [Description("Optional glob to filter files (e.g. '*.cs').")] string? filePattern = null) + [Description("Mode: 'file' for single file, 'recursive' for directory search.")] string mode = "recursive", + [Description("Optional glob to filter files in recursive mode (e.g. '*.cs').")] string? filePattern = null) { path = ResolvePath(path); - Log($"Recursive grep: {pattern} in {path}" + (filePattern != null ? $" (files: {filePattern})" : "")); + mode = mode.ToLowerInvariant(); - if (!Directory.Exists(path)) - return $"ERROR: Directory not found: {path}"; - - Regex regex; - try + if (mode == "file") { - regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); - } - catch (Exception ex) - { - return $"ERROR: Invalid regex pattern '{pattern}': {ex.Message}"; - } + Log($"Searching file: {path}"); - try - { - string globPattern = filePattern?.Replace("**/", "") ?? "*"; - var sb = new System.Text.StringBuilder(); - int totalMatches = 0; + if (!File.Exists(path)) + return $"ERROR: File not found: {path}"; - foreach (var file in EnumerateFilesRecursive(path, globPattern)) + Regex regex; + try { - try - { - // Skip binary files: check first 512 bytes for null chars - using var probe = new StreamReader(file); - var buf = new char[512]; - int read = probe.Read(buf, 0, buf.Length); - if (new ReadOnlySpan(buf, 0, read).Contains('\0')) - continue; - } - catch { continue; } - - try - { - string[] lines = File.ReadAllLines(file); - for (int i = 0; i < lines.Length; i++) - { - if (regex.IsMatch(lines[i])) - { - int lineNumber = i + 1; - string hash = HashlineEncoder.ComputeHash(lines[i].AsSpan(), lineNumber); - sb.Append(file).Append(':').Append(lineNumber).Append(':').Append(hash).Append('|').AppendLine(lines[i]); - totalMatches++; - } - } - } - catch - { - // Skip files that can't be read - } + regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); + } + catch (Exception ex) + { + return $"ERROR: Invalid regex pattern '{pattern}': {ex.Message}"; } - if (totalMatches == 0) - return $"(no matches for '{pattern}' in {path})"; + try + { + string[] lines = File.ReadAllLines(path); + var sb = new System.Text.StringBuilder(); + int matchCount = 0; - return $"Found {totalMatches} match(es):\n" + sb.ToString(); + for (int i = 0; i < lines.Length; i++) + { + if (regex.IsMatch(lines[i])) + { + int lineNumber = i + 1; + string hash = HashlineEncoder.ComputeHash(lines[i].AsSpan(), lineNumber); + sb.Append(lineNumber).Append(':').Append(hash).Append('|').AppendLine(lines[i]); + matchCount++; + } + } + + if (matchCount == 0) + return $"(no matches for '{pattern}' in {path})"; + + return sb.ToString(); + } + catch (Exception ex) + { + return $"ERROR searching '{path}': {ex.Message}"; + } } - catch (Exception ex) + else if (mode == "recursive") { - return $"ERROR in recursive grep: {ex.Message}"; + Log($"Recursive grep: {pattern} in {path}" + (filePattern != null ? $" (files: {filePattern})" : "")); + + if (!Directory.Exists(path)) + return $"ERROR: Directory not found: {path}"; + + Regex regex; + try + { + regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); + } + catch (Exception ex) + { + return $"ERROR: Invalid regex pattern '{pattern}': {ex.Message}"; + } + + try + { + string globPattern = filePattern?.Replace("**/", "") ?? "*"; + var sb = new System.Text.StringBuilder(); + int totalMatches = 0; + + foreach (var file in EnumerateFilesRecursive(path, globPattern)) + { + try + { + // Skip binary files: check first 512 bytes for null chars + using var probe = new StreamReader(file); + var buf = new char[512]; + int read = probe.Read(buf, 0, buf.Length); + if (new ReadOnlySpan(buf, 0, read).Contains('\0')) + continue; + } + catch { continue; } + + try + { + string[] lines = File.ReadAllLines(file); + for (int i = 0; i < lines.Length; i++) + { + if (regex.IsMatch(lines[i])) + { + int lineNumber = i + 1; + string hash = HashlineEncoder.ComputeHash(lines[i].AsSpan(), lineNumber); + sb.Append(file).Append(':').Append(lineNumber).Append(':').Append(hash).Append('|').AppendLine(lines[i]); + totalMatches++; + } + } + } + catch + { + // Skip files that can't be read + } + } + + if (totalMatches == 0) + return $"(no matches for '{pattern}' in {path})"; + + return $"Found {totalMatches} match(es):\n" + sb.ToString(); + } + catch (Exception ex) + { + return $"ERROR in recursive grep: {ex.Message}"; + } + } + else + { + return $"ERROR: Invalid mode '{mode}'. Use 'file' or 'recursive'."; } } diff --git a/docs/ToolConsolidation.md b/docs/ToolConsolidation.md index 06ff080..e48341f 100644 --- a/docs/ToolConsolidation.md +++ b/docs/ToolConsolidation.md @@ -49,7 +49,7 @@ public static string MoveFile( - Both create parent directories - Similar error handling patterns -## 3. Grep Operations +## 4. Grep Operations ✅ DONE **Current tools:** `GrepFile`, `GrepRecursive` @@ -59,13 +59,13 @@ public static string MoveFile( public static string Grep( string path, string pattern, - bool recursive = false, + string mode = "recursive", string? filePattern = null) ``` **Behavior:** -- `recursive=false` - Searches single file (current GrepFile) -- `recursive=true` - Searches directory recursively (current GrepRecursive) +- `mode="file"` - Searches single file (current GrepFile) +- `mode="recursive"` - Searches directory recursively (current GrepRecursive) - `filePattern` - Optional glob to filter files when recursive **Benefits:** @@ -73,7 +73,7 @@ public static string Grep( - Reduces 2 tools to 1 - Cleaner API for LLM -## 4. Delete Operations +## 5. Delete Operations ✅ DONE **Current tools:** `DeleteFile`, `DeleteDir` @@ -82,31 +82,24 @@ public static string Grep( ```csharp public static string Delete( string path, - bool recursive = true) + string mode = "file") ``` **Behavior:** -- Auto-detects if path is file or directory -- `recursive=true` - Delete directory and all contents -- `recursive=false` - Only matters for directories (error if not empty) +- `mode="file"` - Deletes a file +- `mode="dir"` - Deletes a directory (recursive) **Benefits:** -- Auto-detects file vs directory +- Unified interface for all deletion - Similar error handling patterns - Reduces 2 tools to 1 -## Summary +These consolidations reduced the tool count from 17 to 13 tools (4 completed), making the API simpler and easier for the LLM to use effectively. -These consolidations would reduce the tool count from 17 to 13 tools, making the API simpler and easier for the LLM to use effectively. +**Completed merges**: +1. ✅ File Move Operations (2 → 1) - **DONE** +2. ✅ File Write Operations (3 → 1) - **DONE** +3. ✅ Delete Operations (2 → 1) - **DONE** +4. ✅ Grep Operations (2 → 1) - **DONE** -**High priority merges:** -1. ✅ File Write Operations (3 → 1) -2. ✅ File Move Operations (2 → 1) -3. ✅ Grep Operations (2 → 1) -4. ✅ Delete Operations (2 → 1) - -**Kept separate:** -- `ReadFile` - distinct read-only operation -- `ListDir`, `FindFiles`, `GetFileInfo` - different purposes -- `CreateDir` - simple enough to keep standalone -- `ReplaceLines`, `InsertAfter`, `DeleteRange` - too complex to merge without confusing LLM +**All high priority merges completed!**