summaryrefslogtreecommitdiffhomepage
path: root/internal/llm/tools
diff options
context:
space:
mode:
Diffstat (limited to 'internal/llm/tools')
-rw-r--r--internal/llm/tools/bash.go4
-rw-r--r--internal/llm/tools/edit.go7
-rw-r--r--internal/llm/tools/fetch.go223
3 files changed, 232 insertions, 2 deletions
diff --git a/internal/llm/tools/bash.go b/internal/llm/tools/bash.go
index 0b9e13b85..1481bdd88 100644
--- a/internal/llm/tools/bash.go
+++ b/internal/llm/tools/bash.go
@@ -97,7 +97,9 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
ToolName: BashToolName,
Action: "execute",
Description: fmt.Sprintf("Execute command: %s", params.Command),
- Params: BashPermissionsParams(params),
+ Params: BashPermissionsParams{
+ Command: params.Command,
+ },
},
)
if !p {
diff --git a/internal/llm/tools/edit.go b/internal/llm/tools/edit.go
index a5877044b..f12a1eb21 100644
--- a/internal/llm/tools/edit.go
+++ b/internal/llm/tools/edit.go
@@ -260,7 +260,12 @@ func replaceContent(filePath, oldString, newString string) (string, error) {
}
newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
- diff := GenerateDiff(oldString, newContent)
+
+ startIndex := max(0, index-3)
+ oldEndIndex := min(len(oldContent), index+len(oldString)+3)
+ newEndIndex := min(len(newContent), index+len(newString)+3)
+
+ diff := GenerateDiff(oldContent[startIndex:oldEndIndex], newContent[startIndex:newEndIndex])
p := permission.Default.Request(
permission.CreatePermissionRequest{
diff --git a/internal/llm/tools/fetch.go b/internal/llm/tools/fetch.go
new file mode 100644
index 000000000..0a852626c
--- /dev/null
+++ b/internal/llm/tools/fetch.go
@@ -0,0 +1,223 @@
+package tools
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ md "github.com/JohannesKaufmann/html-to-markdown"
+ "github.com/PuerkitoBio/goquery"
+ "github.com/kujtimiihoxha/termai/internal/config"
+ "github.com/kujtimiihoxha/termai/internal/permission"
+)
+
+const (
+ FetchToolName = "fetch"
+ fetchToolDescription = `Fetches content from a URL and returns it in the specified format.
+
+WHEN TO USE THIS TOOL:
+- Use when you need to download content from a URL
+- Helpful for retrieving documentation, API responses, or web content
+- Useful for getting external information to assist with tasks
+
+HOW TO USE:
+- Provide the URL to fetch content from
+- Specify the desired output format (text, markdown, or html)
+- Optionally set a timeout for the request
+
+FEATURES:
+- Supports three output formats: text, markdown, and html
+- Automatically handles HTTP redirects
+- Sets reasonable timeouts to prevent hanging
+- Validates input parameters before making requests
+
+LIMITATIONS:
+- Maximum response size is 5MB
+- Only supports HTTP and HTTPS protocols
+- Cannot handle authentication or cookies
+- Some websites may block automated requests
+
+TIPS:
+- Use text format for plain text content or simple API responses
+- Use markdown format for content that should be rendered with formatting
+- Use html format when you need the raw HTML structure
+- Set appropriate timeouts for potentially slow websites`
+)
+
+type FetchParams struct {
+ URL string `json:"url"`
+ Format string `json:"format"`
+ Timeout int `json:"timeout,omitempty"`
+}
+
+type FetchPermissionsParams struct {
+ URL string `json:"url"`
+ Format string `json:"format"`
+ Timeout int `json:"timeout,omitempty"`
+}
+
+type fetchTool struct {
+ client *http.Client
+}
+
+func NewFetchTool() BaseTool {
+ return &fetchTool{
+ client: &http.Client{
+ Timeout: 30 * time.Second,
+ },
+ }
+}
+
+func (t *fetchTool) Info() ToolInfo {
+ return ToolInfo{
+ Name: FetchToolName,
+ Description: fetchToolDescription,
+ Parameters: map[string]any{
+ "url": map[string]any{
+ "type": "string",
+ "description": "The URL to fetch content from",
+ },
+ "format": map[string]any{
+ "type": "string",
+ "description": "The format to return the content in (text, markdown, or html)",
+ },
+ "timeout": map[string]any{
+ "type": "number",
+ "description": "Optional timeout in seconds (max 120)",
+ },
+ },
+ Required: []string{"url", "format"},
+ }
+}
+
+func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
+ var params FetchParams
+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
+ return NewTextErrorResponse("Failed to parse fetch parameters: " + err.Error()), nil
+ }
+
+ if params.URL == "" {
+ return NewTextErrorResponse("URL parameter is required"), nil
+ }
+
+ format := strings.ToLower(params.Format)
+ if format != "text" && format != "markdown" && format != "html" {
+ return NewTextErrorResponse("Format must be one of: text, markdown, html"), nil
+ }
+
+ if !strings.HasPrefix(params.URL, "http://") && !strings.HasPrefix(params.URL, "https://") {
+ return NewTextErrorResponse("URL must start with http:// or https://"), nil
+ }
+
+ p := permission.Default.Request(
+ permission.CreatePermissionRequest{
+ Path: config.WorkingDirectory(),
+ ToolName: FetchToolName,
+ Action: "fetch",
+ Description: fmt.Sprintf("Fetch content from URL: %s", params.URL),
+ Params: FetchPermissionsParams{
+ URL: params.URL,
+ Format: params.Format,
+ Timeout: params.Timeout,
+ },
+ },
+ )
+
+ if !p {
+ return NewTextErrorResponse("Permission denied to fetch from URL: " + params.URL), nil
+ }
+
+ client := t.client
+ if params.Timeout > 0 {
+ maxTimeout := 120 // 2 minutes
+ if params.Timeout > maxTimeout {
+ params.Timeout = maxTimeout
+ }
+ client = &http.Client{
+ Timeout: time.Duration(params.Timeout) * time.Second,
+ }
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "GET", params.URL, nil)
+ if err != nil {
+ return NewTextErrorResponse("Failed to create request: " + err.Error()), nil
+ }
+
+ req.Header.Set("User-Agent", "termai/1.0")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return NewTextErrorResponse("Failed to execute request: " + err.Error()), nil
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
+ }
+
+ maxSize := int64(5 * 1024 * 1024) // 5MB
+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
+ if err != nil {
+ return NewTextErrorResponse("Failed to read response body: " + err.Error()), nil
+ }
+
+ content := string(body)
+ contentType := resp.Header.Get("Content-Type")
+
+ switch format {
+ case "text":
+ if strings.Contains(contentType, "text/html") {
+ text, err := extractTextFromHTML(content)
+ if err != nil {
+ return NewTextErrorResponse("Failed to extract text from HTML: " + err.Error()), nil
+ }
+ return NewTextResponse(text), nil
+ }
+ return NewTextResponse(content), nil
+
+ case "markdown":
+ if strings.Contains(contentType, "text/html") {
+ markdown, err := convertHTMLToMarkdown(content)
+ if err != nil {
+ return NewTextErrorResponse("Failed to convert HTML to Markdown: " + err.Error()), nil
+ }
+ return NewTextResponse(markdown), nil
+ }
+
+ return NewTextResponse("```\n" + content + "\n```"), nil
+
+ case "html":
+ return NewTextResponse(content), nil
+
+ default:
+ return NewTextResponse(content), nil
+ }
+}
+
+func extractTextFromHTML(html string) (string, error) {
+ doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
+ if err != nil {
+ return "", err
+ }
+
+ text := doc.Text()
+ text = strings.Join(strings.Fields(text), " ")
+
+ return text, nil
+}
+
+func convertHTMLToMarkdown(html string) (string, error) {
+ converter := md.NewConverter("", true, nil)
+
+ markdown, err := converter.ConvertString(html)
+ if err != nil {
+ return "", err
+ }
+
+ return markdown, nil
+}
+