Files
Sidekick/pkg/sidekick/internal_tools.go
T

379 lines
10 KiB
Go
Raw Normal View History

2026-03-21 16:20:16 +02:00
package sidekick
import (
"fmt"
"os"
2026-03-21 17:11:17 +02:00
"os/exec"
2026-03-21 16:20:16 +02:00
"regexp"
"strings"
)
// DefaultInternalTools returns a map of standard file I/O tools
func DefaultInternalTools() map[string]ToolDefinition {
return map[string]ToolDefinition{
"read_file": {
2026-03-22 12:01:29 +02:00
Name: "read_file",
Description: "Read a file, optionally with line windowing. " +
"Every line is prefixed with 'line:hash:' (e.g. '1:abcd:content').",
2026-03-21 16:20:16 +02:00
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
2026-03-22 12:01:29 +02:00
"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"},
2026-03-21 16:20:16 +02:00
},
"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": {
2026-03-22 12:01:29 +02:00
Name: "grep_file",
Description: "Regex search with surrounding context. " +
"Every line is prefixed with 'line:hash:' (e.g. '1:abcd:content').",
2026-03-21 16:20:16 +02:00
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": {
2026-03-22 12:01:29 +02:00
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.",
2026-03-21 16:20:16 +02:00
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,
},
2026-03-21 17:11:17 +02:00
"shell_exec": {
Name: "shell_exec",
Description: "Run a POSIX shell command and return its combined stdout and stderr.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"command": map[string]interface{}{"type": "string"},
},
"required": []string{"command"},
},
Internal: true,
Handler: shellExecHandler,
},
2026-03-21 16:20:16 +02:00
}
}
2026-03-21 17:11:17 +02:00
func shellExecHandler(args map[string]interface{}) (string, error) {
cmdStr, ok := args["command"].(string)
if !ok {
return "", fmt.Errorf("command is required")
}
cmd := exec.Command("sh", "-c", cmdStr)
output, err := cmd.CombinedOutput()
result := string(output)
if err != nil {
result = fmt.Sprintf("%s\nError: %v", result, err)
}
if result == "" {
return "(empty output)", nil
}
return result, nil
}
2026-03-21 16:20:16 +02:00
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)
}