338 lines
9.2 KiB
Go
338 lines
9.2 KiB
Go
package sidekick
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// DefaultInternalTools returns a map of standard file I/O tools
|
|
func DefaultInternalTools() map[string]ToolDefinition {
|
|
return map[string]ToolDefinition{
|
|
"read_file": {
|
|
Name: "read_file",
|
|
Description: "Read a file, optionally with line windowing. Every line is prefixed with 'line:hash:' (e.g. '1:abcd:content').",
|
|
Parameters: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"path": map[string]interface{}{"type": "string"},
|
|
"offset": map[string]interface{}{"type": "integer", "description": "Starting line number (0-indexed)"},
|
|
"limit": map[string]interface{}{"type": "integer", "description": "Number of lines to read"},
|
|
},
|
|
"required": []string{"path"},
|
|
},
|
|
Internal: true,
|
|
Handler: readFileHandler,
|
|
},
|
|
"write_file": {
|
|
Name: "write_file",
|
|
Description: "Overwrite a file with new content. Automatically strips 'line:hash:' prefixes if present.",
|
|
Parameters: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"path": map[string]interface{}{"type": "string"},
|
|
"content": map[string]interface{}{"type": "string"},
|
|
},
|
|
"required": []string{"path", "content"},
|
|
},
|
|
Internal: true,
|
|
Handler: writeFileHandler,
|
|
},
|
|
"grep_file": {
|
|
Name: "grep_file",
|
|
Description: "Regex search with surrounding context. Every line is prefixed with 'line:hash:' (e.g. '1:abcd:content').",
|
|
Parameters: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"path": map[string]interface{}{"type": "string"},
|
|
"pattern": map[string]interface{}{"type": "string"},
|
|
"context_lines": map[string]interface{}{"type": "integer"},
|
|
},
|
|
"required": []string{"path", "pattern"},
|
|
},
|
|
Internal: true,
|
|
Handler: grepFileHandler,
|
|
},
|
|
"edit_file": {
|
|
Name: "edit_file",
|
|
Description: "Replace a string or a block of lines in a file. If 'old_string' contains 'line:hash:' prefixes for every line, it performs a precise replacement at the specified line numbers. Otherwise, it performs a standard first-occurrence replacement. Both multiline strings and 'line:hash:' stripping are supported.",
|
|
Parameters: map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"path": map[string]interface{}{"type": "string"},
|
|
"old_string": map[string]interface{}{"type": "string"},
|
|
"new_string": map[string]interface{}{"type": "string"},
|
|
},
|
|
"required": []string{"path", "old_string", "new_string"},
|
|
},
|
|
Internal: true,
|
|
Handler: editFileHandler,
|
|
},
|
|
}
|
|
}
|
|
|
|
func readFileHandler(args map[string]interface{}) (string, error) {
|
|
path, ok := args["path"].(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("path is required")
|
|
}
|
|
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
lines := strings.Split(string(content), "\n")
|
|
|
|
offset := 0
|
|
if off, ok := args["offset"].(float64); ok {
|
|
offset = int(off)
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
limit := len(lines)
|
|
if lim, ok := args["limit"].(float64); ok {
|
|
limit = int(lim)
|
|
}
|
|
|
|
end := offset + limit
|
|
if end > len(lines) {
|
|
end = len(lines)
|
|
}
|
|
if offset >= len(lines) {
|
|
return "", nil // Empty reading window
|
|
}
|
|
|
|
var outputLines []string
|
|
for i, line := range lines[offset:end] {
|
|
lineNum := offset + i + 1
|
|
outputLines = append(outputLines, fmt.Sprintf("%d:%s:%s", lineNum, hashLine(line), line))
|
|
}
|
|
|
|
return strings.Join(outputLines, "\n"), nil
|
|
}
|
|
|
|
func writeFileHandler(args map[string]interface{}) (string, error) {
|
|
path, ok := args["path"].(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("path is required")
|
|
}
|
|
content, ok := args["content"].(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("content is required")
|
|
}
|
|
|
|
content = stripHashlines(content)
|
|
|
|
err := os.WriteFile(path, []byte(content), 0644)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("Successfully wrote to %s", path), nil
|
|
}
|
|
|
|
func grepFileHandler(args map[string]interface{}) (string, error) {
|
|
path, ok := args["path"].(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("path is required")
|
|
}
|
|
pattern, ok := args["pattern"].(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("pattern is required")
|
|
}
|
|
|
|
contextLines := 0
|
|
if cl, ok := args["context_lines"].(float64); ok {
|
|
contextLines = int(cl)
|
|
}
|
|
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
lines := strings.Split(string(content), "\n")
|
|
re, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid regex: %w", err)
|
|
}
|
|
|
|
var results []string
|
|
for i, line := range lines {
|
|
if re.MatchString(line) {
|
|
start := i - contextLines
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end := i + contextLines + 1
|
|
if end > len(lines) {
|
|
end = len(lines)
|
|
}
|
|
|
|
results = append(results, fmt.Sprintf("--- Match around line %d ---", i+1))
|
|
for j := start; j < end; j++ {
|
|
results = append(results, fmt.Sprintf("%d:%s:%s", j+1, hashLine(lines[j]), lines[j]))
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
return "No matches found.", nil
|
|
}
|
|
return strings.Join(results, "\n"), nil
|
|
}
|
|
func editFileHandler(args map[string]interface{}) (string, error) {
|
|
path, ok := args["path"].(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("path is required")
|
|
}
|
|
oldStr, ok := args["old_string"].(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("old_string is required")
|
|
}
|
|
newStr, ok := args["new_string"].(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("new_string is required")
|
|
}
|
|
|
|
newStr = stripHashlines(newStr)
|
|
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
strContent := string(content)
|
|
|
|
hasHashlines, startLine, strippedOldStr := parseHashlines(oldStr)
|
|
|
|
if hasHashlines && startLine > 0 {
|
|
fileLines := strings.Split(strContent, "\n")
|
|
oldLines := strings.Split(strippedOldStr, "\n")
|
|
|
|
startIdx := startLine - 1
|
|
endIdx := startIdx + len(oldLines)
|
|
|
|
if startIdx >= 0 && endIdx <= len(fileLines) {
|
|
match := true
|
|
for i := 0; i < len(oldLines); i++ {
|
|
if fileLines[startIdx+i] != oldLines[i] {
|
|
match = false
|
|
break
|
|
}
|
|
}
|
|
|
|
if match {
|
|
var finalLines []string
|
|
finalLines = append(finalLines, fileLines[:startIdx]...)
|
|
if newStr != "" {
|
|
finalLines = append(finalLines, strings.Split(newStr, "\n")...)
|
|
}
|
|
finalLines = append(finalLines, fileLines[endIdx:]...)
|
|
|
|
newContent := strings.Join(finalLines, "\n")
|
|
err = os.WriteFile(path, []byte(newContent), 0644)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("Successfully edited %s", path), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to simple replacement
|
|
strippedOldStr = stripHashlines(oldStr)
|
|
if !strings.Contains(strContent, strippedOldStr) {
|
|
return "", fmt.Errorf("old_string not found in file")
|
|
}
|
|
|
|
// Replace first occurrence only
|
|
newContent := strings.Replace(strContent, strippedOldStr, newStr, 1)
|
|
|
|
err = os.WriteFile(path, []byte(newContent), 0644)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return fmt.Sprintf("Successfully edited %s", path), nil
|
|
}
|
|
|
|
func parseHashlines(s string) (bool, int, string) {
|
|
if s == "" {
|
|
return false, 0, s
|
|
}
|
|
var result []string
|
|
lines := strings.Split(s, "\n")
|
|
startLine := -1
|
|
for i, line := range lines {
|
|
parts := strings.SplitN(line, ":", 3)
|
|
if len(parts) == 3 {
|
|
isDigit := len(parts[0]) > 0
|
|
for _, c := range parts[0] {
|
|
if c < '0' || c > '9' {
|
|
isDigit = false
|
|
break
|
|
}
|
|
}
|
|
isHex := len(parts[1]) == 4
|
|
for _, c := range parts[1] {
|
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
|
isHex = false
|
|
break
|
|
}
|
|
}
|
|
if isDigit && isHex {
|
|
if i == 0 {
|
|
fmt.Sscanf(parts[0], "%d", &startLine)
|
|
}
|
|
result = append(result, parts[2])
|
|
continue
|
|
}
|
|
}
|
|
return false, 0, s
|
|
}
|
|
return true, startLine, strings.Join(result, "\n")
|
|
}
|
|
|
|
func stripHashlines(s string) string {
|
|
var result []string
|
|
lines := strings.Split(s, "\n")
|
|
for _, line := range lines {
|
|
parts := strings.SplitN(line, ":", 3)
|
|
if len(parts) == 3 {
|
|
isDigit := len(parts[0]) > 0
|
|
for _, c := range parts[0] {
|
|
if c < '0' || c > '9' {
|
|
isDigit = false
|
|
break
|
|
}
|
|
}
|
|
isHex := len(parts[1]) == 4
|
|
for _, c := range parts[1] {
|
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
|
isHex = false
|
|
break
|
|
}
|
|
}
|
|
if isDigit && isHex {
|
|
result = append(result, parts[2])
|
|
continue
|
|
}
|
|
}
|
|
result = append(result, line)
|
|
}
|
|
return strings.Join(result, "\n")
|
|
}
|
|
|
|
func hashLine(line string) string {
|
|
var h uint32 = 2166136261
|
|
for i := 0; i < len(line); i++ {
|
|
h ^= uint32(line[i])
|
|
h *= 16777619
|
|
}
|
|
return fmt.Sprintf("%04x", h&0xffff)
|
|
}
|