tools/file: port Read, Write, Edit, Grep, Glob from goq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
345ec744d0
commit
c1e5fceda6
|
|
@ -0,0 +1,188 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
var ToolEdit = tools.ToolInfo{
|
||||
Name: "Edit",
|
||||
Description: `Performs exact string replacements in files.
|
||||
|
||||
Usage:
|
||||
- You must use your ` + "`" + `Read` + "`" + ` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + "| ". Everything after that is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
- The edit will FAIL if ` + "`" + `old_string` + "`" + ` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use ` + "`" + `replace_all` + "`" + ` to change every instance of ` + "`" + `old_string` + "`" + `.
|
||||
- Use ` + "`" + `replace_all` + "`" + ` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`,
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["file_path", "old_string", "new_string"],
|
||||
"properties": {
|
||||
"file_path": {"type": "string", "description": "The absolute path to the file to modify"},
|
||||
"old_string": {"type": "string", "description": "The text to replace"},
|
||||
"new_string": {"type": "string", "description": "The text to replace it with (must be different from old_string)"},
|
||||
"replace_all": {"type": "boolean", "description": "Replace all occurences of old_string (default false)"}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
|
||||
func (s *Server) dispatchEdit(_ context.Context, raw json.RawMessage) (string, error) {
|
||||
var a struct {
|
||||
FilePath string `json:"file_path"`
|
||||
OldString string `json:"old_string"`
|
||||
NewString string `json:"new_string"`
|
||||
ReplaceAll bool `json:"replace_all"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", fmt.Errorf("bad args: %w", err)
|
||||
}
|
||||
if a.FilePath == "" {
|
||||
return "", fmt.Errorf("file_path is required")
|
||||
}
|
||||
if a.OldString == a.NewString {
|
||||
return errText("old_string and new_string must be different"), nil
|
||||
}
|
||||
if !s.wasRead(a.FilePath) {
|
||||
return errText("file must be read first. Use the Read tool to examine the file contents."), nil
|
||||
}
|
||||
|
||||
original, err := readFileChecked(a.FilePath)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
|
||||
newContent, err := robustReplace(original, a.OldString, a.NewString, a.ReplaceAll)
|
||||
if err != nil {
|
||||
return errText("%v", err), nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(a.FilePath, []byte(newContent), 0644); err != nil { //nolint:gosec
|
||||
return "", fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
return "File edited successfully", nil
|
||||
}
|
||||
|
||||
// robustReplace tries exact → whitespace-normalized → indentation-flexible → trimmed-boundary.
|
||||
func robustReplace(content, oldString, newString string, replaceAll bool) (string, error) {
|
||||
if m := findExact(content, oldString); m != "" {
|
||||
return doReplace(content, m, newString, replaceAll)
|
||||
}
|
||||
if m := findWSNormalized(content, oldString); m != "" {
|
||||
return doReplace(content, m, newString, replaceAll)
|
||||
}
|
||||
if m := findIndentFlexible(content, oldString); m != "" {
|
||||
return doReplace(content, m, newString, replaceAll)
|
||||
}
|
||||
if m := findTrimmedBoundary(content, oldString); m != "" {
|
||||
return doReplace(content, m, newString, replaceAll)
|
||||
}
|
||||
return "", fmt.Errorf("old_string not found in file")
|
||||
}
|
||||
|
||||
func doReplace(content, match, newString string, replaceAll bool) (string, error) {
|
||||
if replaceAll {
|
||||
return strings.ReplaceAll(content, match, newString), nil
|
||||
}
|
||||
n := strings.Count(content, match)
|
||||
if n == 0 {
|
||||
return "", fmt.Errorf("match disappeared from content")
|
||||
}
|
||||
if n > 1 {
|
||||
return "", fmt.Errorf("old_string is not unique (%d occurrences). Use replace_all=true or provide more context", n)
|
||||
}
|
||||
return strings.Replace(content, match, newString, 1), nil
|
||||
}
|
||||
|
||||
func findExact(content, find string) string {
|
||||
if strings.Contains(content, find) {
|
||||
return find
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findWSNormalized(content, find string) string {
|
||||
nf := strings.Join(strings.Fields(find), " ")
|
||||
lines := strings.Split(content, "\n")
|
||||
findLines := strings.Split(find, "\n")
|
||||
n := len(findLines)
|
||||
for i := 0; i <= len(lines)-n; i++ {
|
||||
block := strings.Join(lines[i:i+n], "\n")
|
||||
if strings.Join(strings.Fields(block), " ") == nf {
|
||||
return block
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findIndentFlexible(content, find string) string {
|
||||
deindent := func(text string) string {
|
||||
ls := strings.Split(text, "\n")
|
||||
min := 10000
|
||||
for _, l := range ls {
|
||||
if strings.TrimSpace(l) == "" {
|
||||
continue
|
||||
}
|
||||
for i, r := range l {
|
||||
if r != ' ' && r != '\t' {
|
||||
if i < min {
|
||||
min = i
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if min == 10000 {
|
||||
return text
|
||||
}
|
||||
out := make([]string, len(ls))
|
||||
for i, l := range ls {
|
||||
if strings.TrimSpace(l) == "" {
|
||||
out[i] = l
|
||||
} else if len(l) > min {
|
||||
out[i] = l[min:]
|
||||
} else {
|
||||
out[i] = strings.TrimLeft(l, " \t")
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
nf := deindent(find)
|
||||
lines := strings.Split(content, "\n")
|
||||
findLines := strings.Split(find, "\n")
|
||||
n := len(findLines)
|
||||
for i := 0; i <= len(lines)-n; i++ {
|
||||
block := strings.Join(lines[i:i+n], "\n")
|
||||
if deindent(block) == nf {
|
||||
return block
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findTrimmedBoundary(content, find string) string {
|
||||
trimmed := strings.TrimSpace(find)
|
||||
if trimmed == find {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(content, trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
findLines := strings.Split(find, "\n")
|
||||
n := len(findLines)
|
||||
for i := 0; i <= len(lines)-n; i++ {
|
||||
block := strings.Join(lines[i:i+n], "\n")
|
||||
if strings.TrimSpace(block) == trimmed {
|
||||
return block
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
var ToolGlob = tools.ToolInfo{
|
||||
Name: "Glob",
|
||||
Description: `Fast file pattern matching. Find files by name/path patterns.
|
||||
|
||||
Usage:
|
||||
- Works with any codebase size
|
||||
- Results sorted by modification time
|
||||
- ripgrep respects .gitignore and .ignore files by default
|
||||
- Use ** for recursive path matches (for example, **/*.js)
|
||||
- Use instead of find or ls in Bash
|
||||
- Can speculatively launch multiple Glob calls in parallel`,
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["pattern"],
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Glob pattern, e.g. '**/*.js', 'src/**/*.ts'"},
|
||||
"path": {"type": "string", "description": "Directory to search in. Omit for current working directory. Do NOT enter 'undefined' or 'null'."}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
|
||||
func (s *Server) dispatchGlob(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var a struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", fmt.Errorf("bad args: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(a.Pattern) == "" {
|
||||
return "", fmt.Errorf("pattern is required")
|
||||
}
|
||||
if s.rgPath == "" {
|
||||
return "", fmt.Errorf("ripgrep (rg) not found in PATH")
|
||||
}
|
||||
|
||||
searchPath := a.Path
|
||||
if searchPath == "" {
|
||||
searchPath = s.projectDir
|
||||
}
|
||||
searchPath = filepath.Clean(searchPath)
|
||||
|
||||
if err := ensureSearchPath(searchPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, s.rgPath, "--no-config", "--files", "-g", a.Pattern, searchPath)
|
||||
cmd.Dir = s.projectDir
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return "", nil
|
||||
}
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
path string
|
||||
modTime time.Time
|
||||
}
|
||||
lines := splitLines(stdout.String())
|
||||
entries := make([]entry, 0, len(lines))
|
||||
for _, p := range lines {
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry{p, info.ModTime()})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].modTime.Equal(entries[j].modTime) {
|
||||
return entries[i].path < entries[j].path
|
||||
}
|
||||
return entries[i].modTime.After(entries[j].modTime)
|
||||
})
|
||||
|
||||
paths := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
paths[i] = e.path
|
||||
}
|
||||
return strings.Join(paths, "\n"), nil
|
||||
}
|
||||
|
||||
// statPath wraps os.Stat for use by grep.go and glob.go.
|
||||
func statPath(path string) (os.FileInfo, error) {
|
||||
return os.Stat(path)
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
var ToolGrep = tools.ToolInfo{
|
||||
Name: "Grep",
|
||||
Description: `Search file contents using ripgrep. The primary content search tool.
|
||||
|
||||
Usage:
|
||||
- Always use Grep instead of grep or rg in Bash
|
||||
- Uses ripgrep syntax (not GNU grep); literal braces need escaping as \{ and \}
|
||||
- ripgrep respects .gitignore and .ignore files by default
|
||||
- files_with_matches is the default mode (just file paths)
|
||||
- multiline: true is needed for patterns spanning lines
|
||||
- Supports pagination via offset + head_limit`,
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["pattern"],
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Regex pattern to search for (ripgrep syntax)"},
|
||||
"path": {"type": "string", "description": "File or directory to search in. Defaults to cwd."},
|
||||
"output_mode": {"type": "string", "description": "Default: 'files_with_matches'. 'content' shows matching lines, 'count' shows match counts.", "enum": ["content", "files_with_matches", "count"]},
|
||||
"glob": {"type": "string", "description": "Glob filter, e.g. '*.js', '*.{ts,tsx}'"},
|
||||
"type": {"type": "string", "description": "File type filter, e.g. 'js', 'py', 'rust'. More efficient than glob for standard types."},
|
||||
"-i": {"type": "boolean", "description": "Case insensitive search"},
|
||||
"-n": {"type": "boolean", "description": "Show line numbers (default true). Requires output_mode: 'content'."},
|
||||
"-A": {"type": "number", "description": "Lines after match"},
|
||||
"-B": {"type": "number", "description": "Lines before match"},
|
||||
"-C": {"type": "number", "description": "Lines before and after match (short flag alias)"},
|
||||
"context": {"type": "number", "description": "Lines before and after match"},
|
||||
"multiline": {"type": "boolean", "description": "Enable multiline mode where . matches newlines. Default: false."},
|
||||
"head_limit": {"type": "number", "description": "Limit output to first N lines/entries (like | head -N). Default: 0 (unlimited)."},
|
||||
"offset": {"type": "number", "description": "Skip first N entries before applying head_limit (like | tail -n +N | head -N). Default: 0."}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
|
||||
func (s *Server) dispatchGrep(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
var a struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Path string `json:"path"`
|
||||
OutputMode string `json:"output_mode"`
|
||||
Glob string `json:"glob"`
|
||||
Type string `json:"type"`
|
||||
IgnoreCase bool `json:"-i"`
|
||||
LineNumber *bool `json:"-n"`
|
||||
After *int `json:"-A"`
|
||||
Before *int `json:"-B"`
|
||||
ContextC *int `json:"-C"`
|
||||
Context *int `json:"context"`
|
||||
Multiline bool `json:"multiline"`
|
||||
HeadLimit *int `json:"head_limit"`
|
||||
Offset *int `json:"offset"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", fmt.Errorf("bad args: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(a.Pattern) == "" {
|
||||
return "", fmt.Errorf("pattern is required")
|
||||
}
|
||||
if s.rgPath == "" {
|
||||
return "", fmt.Errorf("ripgrep (rg) not found in PATH")
|
||||
}
|
||||
|
||||
outputMode := a.OutputMode
|
||||
if outputMode == "" {
|
||||
outputMode = "files_with_matches"
|
||||
}
|
||||
|
||||
searchPath := a.Path
|
||||
if searchPath == "" {
|
||||
searchPath = s.projectDir
|
||||
}
|
||||
searchPath = filepath.Clean(searchPath)
|
||||
|
||||
if err := ensureSearchPath(searchPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmdArgs := []string{"--no-config", "--color=never"}
|
||||
switch outputMode {
|
||||
case "files_with_matches":
|
||||
cmdArgs = append(cmdArgs, "-l")
|
||||
case "count":
|
||||
cmdArgs = append(cmdArgs, "--count")
|
||||
case "content":
|
||||
showLineNums := true
|
||||
if a.LineNumber != nil {
|
||||
showLineNums = *a.LineNumber
|
||||
}
|
||||
if showLineNums {
|
||||
cmdArgs = append(cmdArgs, "-n")
|
||||
}
|
||||
}
|
||||
if a.IgnoreCase {
|
||||
cmdArgs = append(cmdArgs, "-i")
|
||||
}
|
||||
if a.Multiline {
|
||||
cmdArgs = append(cmdArgs, "-U", "--multiline-dotall")
|
||||
}
|
||||
if a.Glob != "" {
|
||||
cmdArgs = append(cmdArgs, "-g", a.Glob)
|
||||
}
|
||||
if a.Type != "" {
|
||||
cmdArgs = append(cmdArgs, "-t", a.Type)
|
||||
}
|
||||
ctx_ := a.Context
|
||||
if ctx_ == nil {
|
||||
ctx_ = a.ContextC
|
||||
}
|
||||
if ctx_ != nil {
|
||||
cmdArgs = append(cmdArgs, "-C", fmt.Sprintf("%d", *ctx_))
|
||||
}
|
||||
if a.Before != nil {
|
||||
cmdArgs = append(cmdArgs, "-B", fmt.Sprintf("%d", *a.Before))
|
||||
}
|
||||
if a.After != nil {
|
||||
cmdArgs = append(cmdArgs, "-A", fmt.Sprintf("%d", *a.After))
|
||||
}
|
||||
cmdArgs = append(cmdArgs, "-e", a.Pattern, searchPath)
|
||||
|
||||
cmd := exec.CommandContext(ctx, s.rgPath, cmdArgs...)
|
||||
cmd.Dir = s.projectDir
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return "", nil // no matches
|
||||
}
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
lines := splitLines(stdout.String())
|
||||
lines = paginate(lines, intOrZero(a.Offset), intOrZero(a.HeadLimit))
|
||||
|
||||
if outputMode == "content" {
|
||||
lines = formatContentLines(lines)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
func formatContentLines(lines []string) []string {
|
||||
out := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == "--" {
|
||||
out = append(out, line)
|
||||
continue
|
||||
}
|
||||
if p, rest, ok := strings.Cut(line, ":"); ok {
|
||||
if ln, content, ok2 := strings.Cut(rest, ":"); ok2 && isDigits(ln) {
|
||||
out = append(out, fmt.Sprintf("%s:%05s| %s", p, ln, content))
|
||||
continue
|
||||
}
|
||||
}
|
||||
if p, rest, ok := strings.Cut(line, "-"); ok {
|
||||
if ln, content, ok2 := strings.Cut(rest, "-"); ok2 && isDigits(ln) {
|
||||
out = append(out, fmt.Sprintf("%s:%05s| %s", p, ln, content))
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ensureSearchPath(path string) error {
|
||||
info, err := statPath(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error accessing path: %w", err)
|
||||
}
|
||||
if !info.IsDir() && !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("not a searchable path: %s", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
var ToolRead = tools.ToolInfo{
|
||||
Name: "Read",
|
||||
Description: `Reads a file from the local filesystem. You can access any file directly by using this tool.
|
||||
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
||||
|
||||
Usage:
|
||||
- The file_path parameter must be an absolute path, not a relative path
|
||||
- By default, it reads up to 2000 lines starting from the beginning of the file
|
||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||
- Any lines longer than 2000 characters will be truncated
|
||||
- Results are returned with line numbers in the format: line number + "| " + content, starting at 1
|
||||
- This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.
|
||||
- You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel.
|
||||
- If you read a file that exists but has empty contents "(empty file)" will be returned.`,
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["file_path"],
|
||||
"properties": {
|
||||
"file_path": {"type": "string", "description": "The absolute path to the file to read"},
|
||||
"offset": {"type": "number", "description": "The line number to start reading from. Only provide if the file is too large to read at once"},
|
||||
"limit": {"type": "number", "description": "The number of lines to read. Only provide if the file is too large to read at once."}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
|
||||
const (
|
||||
maxFileSize = 8 * 1024 * 1024 // 8 MB
|
||||
defaultLimit = 2000
|
||||
maxLineLen = 2000
|
||||
)
|
||||
|
||||
func (s *Server) dispatchRead(_ context.Context, raw json.RawMessage) (string, error) {
|
||||
var a struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Offset *int `json:"offset"`
|
||||
Limit *int `json:"limit"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", fmt.Errorf("bad args: %w", err)
|
||||
}
|
||||
if a.FilePath == "" {
|
||||
return "", fmt.Errorf("file_path is required")
|
||||
}
|
||||
|
||||
file, err := openChecked(a.FilePath)
|
||||
if err != nil {
|
||||
return err.Error(), nil // surface as text
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
off := 1
|
||||
if a.Offset != nil && *a.Offset > 1 {
|
||||
off = *a.Offset
|
||||
}
|
||||
lim := defaultLimit
|
||||
if a.Limit != nil && *a.Limit > 0 {
|
||||
lim = *a.Limit
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
var lines []string
|
||||
lineNum := 1
|
||||
for scanner.Scan() {
|
||||
if lineNum >= off && len(lines) < lim {
|
||||
line := scanner.Text()
|
||||
if len(line) > maxLineLen {
|
||||
line = line[:maxLineLen] + "..."
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%05d| %s", lineNum, line))
|
||||
}
|
||||
lineNum++
|
||||
if lineNum > off+lim {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", fmt.Errorf("reading file: %w", err)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "(empty file)", nil
|
||||
}
|
||||
|
||||
s.markRead(a.FilePath)
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
// openChecked validates and opens a regular file within size limits.
|
||||
func openChecked(path string) (*os.File, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
return nil, fmt.Errorf("error: path must be absolute, got: %s", path)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error accessing file: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("error: not a regular file: %s", path)
|
||||
}
|
||||
if info.Size() > maxFileSize {
|
||||
return nil, fmt.Errorf("error: file too large (%d bytes, max %d)", info.Size(), maxFileSize)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error opening file: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// readFileChecked reads the full content of a regular file within size limits.
|
||||
func readFileChecked(path string) (string, error) {
|
||||
f, err := openChecked(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
info, _ := f.Stat()
|
||||
buf := make([]byte, info.Size())
|
||||
n, err := f.Read(buf)
|
||||
if err != nil && n == 0 {
|
||||
return "", fmt.Errorf("reading file: %w", err)
|
||||
}
|
||||
return string(buf[:n]), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
// Package file implements the Read, Write, Edit, Grep, and Glob tools.
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sync"
|
||||
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
// Server implements tools.Server for file operations.
|
||||
type Server struct {
|
||||
projectDir string
|
||||
rgPath string
|
||||
|
||||
mu sync.Mutex
|
||||
readFiles map[string]struct{}
|
||||
}
|
||||
|
||||
// New creates a Server for the given project directory.
|
||||
func New(projectDir string) *Server {
|
||||
rg, _ := exec.LookPath("rg")
|
||||
return &Server{
|
||||
projectDir: projectDir,
|
||||
rgPath: rg,
|
||||
readFiles: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Decl returns a factory for a file Server with the given project directory.
|
||||
func Decl(projectDir string) func() tools.Server {
|
||||
return func() tools.Server { return New(projectDir) }
|
||||
}
|
||||
|
||||
// SetWorkDir implements tools.WorkDirSetter.
|
||||
func (s *Server) SetWorkDir(dir string) {
|
||||
s.mu.Lock()
|
||||
s.projectDir = dir
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// ListTools implements tools.Server.
|
||||
func (s *Server) ListTools() ([]tools.ToolInfo, error) {
|
||||
return []tools.ToolInfo{ToolRead, ToolWrite, ToolEdit, ToolGrep, ToolGlob}, nil
|
||||
}
|
||||
|
||||
// CallTool implements tools.Server.
|
||||
func (s *Server) CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
||||
var (
|
||||
text string
|
||||
err error
|
||||
)
|
||||
switch tool {
|
||||
case "Read":
|
||||
text, err = s.dispatchRead(ctx, args)
|
||||
case "Write":
|
||||
text, err = s.dispatchWrite(ctx, args)
|
||||
case "Edit":
|
||||
text, err = s.dispatchEdit(ctx, args)
|
||||
case "Grep":
|
||||
text, err = s.dispatchGrep(ctx, args)
|
||||
case "Glob":
|
||||
text, err = s.dispatchGlob(ctx, args)
|
||||
default:
|
||||
text = "unknown tool: " + tool
|
||||
}
|
||||
if err != nil {
|
||||
text = "error: " + err.Error()
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"content": []map[string]string{{"type": "text", "text": text}},
|
||||
})
|
||||
}
|
||||
|
||||
// Close implements tools.Server (no-op).
|
||||
func (s *Server) Close() {}
|
||||
|
||||
func (s *Server) markRead(path string) {
|
||||
s.mu.Lock()
|
||||
s.readFiles[path] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) wasRead(path string) bool {
|
||||
s.mu.Lock()
|
||||
_, ok := s.readFiles[path]
|
||||
s.mu.Unlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
// Compile-time interface checks.
|
||||
var _ tools.Server = (*Server)(nil)
|
||||
var _ tools.WorkDirSetter = (*Server)(nil)
|
||||
|
||||
// Shared argument helpers.
|
||||
|
||||
func intOrZero(p *int) int {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func paginate(items []string, offset, headLimit int) []string {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= len(items) {
|
||||
return nil
|
||||
}
|
||||
items = items[offset:]
|
||||
if headLimit > 0 && headLimit < len(items) {
|
||||
items = items[:headLimit]
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func isDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
s = s[:len(s)-countTrailingNewlines(s)]
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
lines := make([]string, 0, 32)
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
lines = append(lines, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
lines = append(lines, s[start:])
|
||||
return lines
|
||||
}
|
||||
|
||||
func countTrailingNewlines(s string) int {
|
||||
n := 0
|
||||
for i := len(s) - 1; i >= 0 && s[i] == '\n'; i-- {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// errText wraps an error message as a non-error tool result (model sees error text).
|
||||
func errText(format string, args ...any) string {
|
||||
return "error: " + fmt.Sprintf(format, args...)
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
var ToolWrite = tools.ToolInfo{
|
||||
Name: "Write",
|
||||
Description: `Writes a file to the local filesystem.
|
||||
|
||||
Usage:
|
||||
- This tool will overwrite the existing file if there is one at the provided path.
|
||||
- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
||||
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`,
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["file_path", "content"],
|
||||
"properties": {
|
||||
"file_path": {"type": "string", "description": "The absolute path to the file to write (must be absolute, not relative)"},
|
||||
"content": {"type": "string", "description": "The content to write to the file"}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
|
||||
func (s *Server) dispatchWrite(_ context.Context, raw json.RawMessage) (string, error) {
|
||||
var a struct {
|
||||
FilePath string `json:"file_path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", fmt.Errorf("bad args: %w", err)
|
||||
}
|
||||
if a.FilePath == "" {
|
||||
return "", fmt.Errorf("file_path is required")
|
||||
}
|
||||
|
||||
_, statErr := os.Stat(a.FilePath)
|
||||
exists := statErr == nil
|
||||
if exists && !s.wasRead(a.FilePath) {
|
||||
return errText("existing file must be read first. Use the Read tool to examine the file contents."), nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(a.FilePath, []byte(a.Content), 0644); err != nil { //nolint:gosec
|
||||
return "", fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
s.markRead(a.FilePath)
|
||||
return "File written successfully", nil
|
||||
}
|
||||
Reference in New Issue