mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-14 18:17:29 +08:00
Initial commit for ClawGo
This commit is contained in:
21
pkg/tools/base.go
Normal file
21
pkg/tools/base.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package tools
|
||||
|
||||
import "context"
|
||||
|
||||
type Tool interface {
|
||||
Name() string
|
||||
Description() string
|
||||
Parameters() map[string]interface{}
|
||||
Execute(ctx context.Context, args map[string]interface{}) (string, error)
|
||||
}
|
||||
|
||||
func ToolToSchema(tool Tool) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": tool.Name(),
|
||||
"description": tool.Description(),
|
||||
"parameters": tool.Parameters(),
|
||||
},
|
||||
}
|
||||
}
|
||||
71
pkg/tools/camera.go
Normal file
71
pkg/tools/camera.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CameraTool struct {
|
||||
workspace string
|
||||
}
|
||||
|
||||
func NewCameraTool(workspace string) *CameraTool {
|
||||
return &CameraTool{
|
||||
workspace: workspace,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *CameraTool) Name() string {
|
||||
return "camera_snap"
|
||||
}
|
||||
|
||||
func (t *CameraTool) Description() string {
|
||||
return "Take a photo using the system camera (/dev/video0) and save to workspace."
|
||||
}
|
||||
|
||||
func (t *CameraTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"filename": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Optional filename (default: snap_TIMESTAMP.jpg)",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *CameraTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
filename := ""
|
||||
if v, ok := args["filename"].(string); ok && v != "" {
|
||||
filename = v
|
||||
} else {
|
||||
filename = fmt.Sprintf("snap_%d.jpg", time.Now().Unix())
|
||||
}
|
||||
|
||||
// Ensure filename is safe and within workspace
|
||||
filename = filepath.Clean(filename)
|
||||
if filepath.IsAbs(filename) {
|
||||
return "", fmt.Errorf("filename must be relative to workspace")
|
||||
}
|
||||
|
||||
outputPath := filepath.Join(t.workspace, filename)
|
||||
|
||||
// Check if video device exists
|
||||
if _, err := os.Stat("/dev/video0"); os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("camera device /dev/video0 not found")
|
||||
}
|
||||
|
||||
// ffmpeg -y -f video4linux2 -i /dev/video0 -vframes 1 -q:v 2 output.jpg
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-f", "video4linux2", "-i", "/dev/video0", "-vframes", "1", "-q:v", "2", outputPath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Error taking photo: %v\nOutput: %s", err, string(output)), nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Photo saved to %s", filename), nil
|
||||
}
|
||||
176
pkg/tools/edit.go
Normal file
176
pkg/tools/edit.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EditFileTool edits a file by replacing old_text with new_text.
|
||||
// The old_text must exist exactly in the file.
|
||||
type EditFileTool struct {
|
||||
allowedDir string // Optional directory restriction for security
|
||||
}
|
||||
|
||||
// NewEditFileTool creates a new EditFileTool with optional directory restriction.
|
||||
func NewEditFileTool(allowedDir string) *EditFileTool {
|
||||
return &EditFileTool{
|
||||
allowedDir: allowedDir,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *EditFileTool) Name() string {
|
||||
return "edit_file"
|
||||
}
|
||||
|
||||
func (t *EditFileTool) Description() string {
|
||||
return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file."
|
||||
}
|
||||
|
||||
func (t *EditFileTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The file path to edit",
|
||||
},
|
||||
"old_text": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The exact text to find and replace",
|
||||
},
|
||||
"new_text": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The text to replace with",
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "old_text", "new_text"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
path, ok := args["path"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("path is required")
|
||||
}
|
||||
|
||||
oldText, ok := args["old_text"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("old_text is required")
|
||||
}
|
||||
|
||||
newText, ok := args["new_text"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("new_text is required")
|
||||
}
|
||||
|
||||
// Resolve path and enforce directory restriction if configured
|
||||
resolvedPath := path
|
||||
if filepath.IsAbs(path) {
|
||||
resolvedPath = filepath.Clean(path)
|
||||
} else {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve path: %w", err)
|
||||
}
|
||||
resolvedPath = abs
|
||||
}
|
||||
|
||||
// Check directory restriction
|
||||
if t.allowedDir != "" {
|
||||
allowedAbs, err := filepath.Abs(t.allowedDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve allowed directory: %w", err)
|
||||
}
|
||||
if !strings.HasPrefix(resolvedPath, allowedAbs) {
|
||||
return "", fmt.Errorf("path %s is outside allowed directory %s", path, t.allowedDir)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("file not found: %s", path)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(resolvedPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
|
||||
if !strings.Contains(contentStr, oldText) {
|
||||
return "", fmt.Errorf("old_text not found in file. Make sure it matches exactly")
|
||||
}
|
||||
|
||||
count := strings.Count(contentStr, oldText)
|
||||
if count > 1 {
|
||||
return "", fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count)
|
||||
}
|
||||
|
||||
newContent := strings.Replace(contentStr, oldText, newText, 1)
|
||||
|
||||
if err := os.WriteFile(resolvedPath, []byte(newContent), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Successfully edited %s", path), nil
|
||||
}
|
||||
|
||||
type AppendFileTool struct{}
|
||||
|
||||
func NewAppendFileTool() *AppendFileTool {
|
||||
return &AppendFileTool{}
|
||||
}
|
||||
|
||||
func (t *AppendFileTool) Name() string {
|
||||
return "append_file"
|
||||
}
|
||||
|
||||
func (t *AppendFileTool) Description() string {
|
||||
return "Append content to the end of a file"
|
||||
}
|
||||
|
||||
func (t *AppendFileTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The file path to append to",
|
||||
},
|
||||
"content": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The content to append",
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "content"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *AppendFileTool) Execute(ctx context.Context, 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")
|
||||
}
|
||||
|
||||
filePath := filepath.Clean(path)
|
||||
|
||||
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
return "", fmt.Errorf("failed to append to file: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Successfully appended to %s", path), nil
|
||||
}
|
||||
247
pkg/tools/filesystem.go
Normal file
247
pkg/tools/filesystem.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ReadFileTool struct{}
|
||||
|
||||
func (t *ReadFileTool) Name() string {
|
||||
return "read_file"
|
||||
}
|
||||
|
||||
func (t *ReadFileTool) Description() string {
|
||||
return "Read the contents of a file"
|
||||
}
|
||||
|
||||
func (t *ReadFileTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Path to the file to read",
|
||||
},
|
||||
"limit": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "Maximum number of bytes to read",
|
||||
},
|
||||
"offset": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "Byte offset to start reading from",
|
||||
},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
path, ok := args["path"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("path is required")
|
||||
}
|
||||
|
||||
limit := int64(0)
|
||||
if val, ok := args["limit"].(float64); ok {
|
||||
limit = int64(val)
|
||||
}
|
||||
|
||||
offset := int64(0)
|
||||
if val, ok := args["offset"].(float64); ok {
|
||||
offset = int64(val)
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
|
||||
if offset >= info.Size() {
|
||||
return "", nil // Offset beyond file size
|
||||
}
|
||||
|
||||
if _, err := f.Seek(offset, 0); err != nil {
|
||||
return "", fmt.Errorf("failed to seek: %w", err)
|
||||
}
|
||||
|
||||
// Default read all if limit is not set or 0
|
||||
readLimit := info.Size() - offset
|
||||
if limit > 0 && limit < readLimit {
|
||||
readLimit = limit
|
||||
}
|
||||
|
||||
// Safety cap: don't read insanely large files into memory unless requested
|
||||
// But tool says "read file", so we respect limit.
|
||||
// If limit is 0 (unspecified), maybe we should default to a reasonable max?
|
||||
// The original code used os.ReadFile which reads ALL. So I should probably keep that behavior if limit is 0.
|
||||
// However, if limit is explicitly passed as 0, it might mean "read 0 bytes". But usually in JSON APIs 0 means default or none.
|
||||
// Let's assume limit > 0 means limit. If limit <= 0, read until EOF.
|
||||
|
||||
var content []byte
|
||||
if limit > 0 {
|
||||
content = make([]byte, readLimit)
|
||||
n, err := io.ReadFull(f, content)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return "", fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
content = content[:n]
|
||||
} else {
|
||||
// Read until EOF
|
||||
content, err = io.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
type WriteFileTool struct{}
|
||||
|
||||
func (t *WriteFileTool) Name() string {
|
||||
return "write_file"
|
||||
}
|
||||
|
||||
func (t *WriteFileTool) Description() string {
|
||||
return "Write content to a file"
|
||||
}
|
||||
|
||||
func (t *WriteFileTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Path to the file to write",
|
||||
},
|
||||
"content": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Content to write to the file",
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "content"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WriteFileTool) Execute(ctx context.Context, 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")
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
return "File written successfully", nil
|
||||
}
|
||||
|
||||
type ListDirTool struct{}
|
||||
|
||||
func (t *ListDirTool) Name() string {
|
||||
return "list_dir"
|
||||
}
|
||||
|
||||
func (t *ListDirTool) Description() string {
|
||||
return "List files and directories in a path"
|
||||
}
|
||||
|
||||
func (t *ListDirTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Path to list",
|
||||
},
|
||||
"recursive": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "List recursively",
|
||||
},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
path, ok := args["path"].(string)
|
||||
if !ok {
|
||||
path = "."
|
||||
}
|
||||
|
||||
recursive, _ := args["recursive"].(bool)
|
||||
|
||||
var result strings.Builder
|
||||
|
||||
if recursive {
|
||||
err := filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relPath, err := filepath.Rel(path, p)
|
||||
if err != nil {
|
||||
relPath = p
|
||||
}
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
result.WriteString(fmt.Sprintf("DIR: %s\n", relPath))
|
||||
} else {
|
||||
result.WriteString(fmt.Sprintf("FILE: %s\n", relPath))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to walk directory: %w", err)
|
||||
}
|
||||
} else {
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read directory: %w", err)
|
||||
}
|
||||
|
||||
// Sort entries: directories first, then files
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].IsDir() && !entries[j].IsDir() {
|
||||
return true
|
||||
}
|
||||
if !entries[i].IsDir() && entries[j].IsDir() {
|
||||
return false
|
||||
}
|
||||
return entries[i].Name() < entries[j].Name()
|
||||
})
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
result.WriteString(fmt.Sprintf("DIR: %s\n", entry.Name()))
|
||||
} else {
|
||||
result.WriteString(fmt.Sprintf("FILE: %s\n", entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.String(), nil
|
||||
}
|
||||
229
pkg/tools/memory.go
Normal file
229
pkg/tools/memory.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type MemorySearchTool struct {
|
||||
workspace string
|
||||
}
|
||||
|
||||
func NewMemorySearchTool(workspace string) *MemorySearchTool {
|
||||
return &MemorySearchTool{
|
||||
workspace: workspace,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MemorySearchTool) Name() string {
|
||||
return "memory_search"
|
||||
}
|
||||
|
||||
func (t *MemorySearchTool) Description() string {
|
||||
return "Semantically search MEMORY.md and memory/*.md files for information. Returns relevant snippets (paragraphs) containing the query terms."
|
||||
}
|
||||
|
||||
func (t *MemorySearchTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"query": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Search query keywords (e.g., 'docker deploy project')",
|
||||
},
|
||||
"maxResults": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results to return",
|
||||
"default": 5,
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
}
|
||||
}
|
||||
|
||||
type searchResult struct {
|
||||
file string
|
||||
lineNum int
|
||||
content string
|
||||
score int
|
||||
}
|
||||
|
||||
func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
query, ok := args["query"].(string)
|
||||
if !ok || query == "" {
|
||||
return "", fmt.Errorf("query is required")
|
||||
}
|
||||
|
||||
maxResults := 5
|
||||
if m, ok := args["maxResults"].(float64); ok {
|
||||
maxResults = int(m)
|
||||
}
|
||||
|
||||
keywords := strings.Fields(strings.ToLower(query))
|
||||
if len(keywords) == 0 {
|
||||
return "Please provide search keywords.", nil
|
||||
}
|
||||
|
||||
files := t.getMemoryFiles()
|
||||
var results []searchResult
|
||||
|
||||
for _, file := range files {
|
||||
matches, err := t.searchFile(file, keywords)
|
||||
if err != nil {
|
||||
continue // skip unreadable files
|
||||
}
|
||||
results = append(results, matches...)
|
||||
}
|
||||
|
||||
// Simple ranking: sort by score (number of keyword matches) desc
|
||||
// Ideally use a stable sort or more sophisticated scoring
|
||||
for i := 0; i < len(results); i++ {
|
||||
for j := i + 1; j < len(results); j++ {
|
||||
if results[j].score > results[i].score {
|
||||
results[i], results[j] = results[j], results[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) > maxResults {
|
||||
results = results[:maxResults]
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return fmt.Sprintf("No memory found for query: %s", query), nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Found %d memories for '%s':\n\n", len(results), query))
|
||||
for _, res := range results {
|
||||
relPath, _ := filepath.Rel(t.workspace, res.file)
|
||||
sb.WriteString(fmt.Sprintf("--- Source: %s:%d ---\n%s\n\n", relPath, res.lineNum, res.content))
|
||||
}
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (t *MemorySearchTool) getMemoryFiles() []string {
|
||||
var files []string
|
||||
|
||||
// Check main MEMORY.md
|
||||
mainMem := filepath.Join(t.workspace, "MEMORY.md")
|
||||
if _, err := os.Stat(mainMem); err == nil {
|
||||
files = append(files, mainMem)
|
||||
}
|
||||
|
||||
// Check memory/ directory
|
||||
memDir := filepath.Join(t.workspace, "memory")
|
||||
entries, err := os.ReadDir(memDir)
|
||||
if err == nil {
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") {
|
||||
files = append(files, filepath.Join(memDir, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// searchFile parses the markdown file into blocks (paragraphs/list items) and searches them
|
||||
func (t *MemorySearchTool) searchFile(path string, keywords []string) ([]searchResult, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var results []searchResult
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
var currentBlock strings.Builder
|
||||
var blockStartLine int = 1
|
||||
var currentLineNum int = 0
|
||||
var lastHeading string
|
||||
|
||||
processBlock := func() {
|
||||
content := strings.TrimSpace(currentBlock.String())
|
||||
if content != "" {
|
||||
lowerContent := strings.ToLower(content)
|
||||
score := 0
|
||||
// Calculate score: how many keywords are present?
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(lowerContent, kw) {
|
||||
score++
|
||||
}
|
||||
}
|
||||
|
||||
// Add bonus if heading matches
|
||||
if lastHeading != "" {
|
||||
lowerHeading := strings.ToLower(lastHeading)
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(lowerHeading, kw) {
|
||||
score++
|
||||
}
|
||||
}
|
||||
// Prepend heading context if not already part of block
|
||||
if !strings.HasPrefix(content, "#") {
|
||||
content = fmt.Sprintf("[%s]\n%s", lastHeading, content)
|
||||
}
|
||||
}
|
||||
|
||||
// Only keep if at least one keyword matched
|
||||
if score > 0 {
|
||||
results = append(results, searchResult{
|
||||
file: path,
|
||||
lineNum: blockStartLine,
|
||||
content: content,
|
||||
score: score,
|
||||
})
|
||||
}
|
||||
}
|
||||
currentBlock.Reset()
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
currentLineNum++
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Markdown Block Logic:
|
||||
// 1. Headers start new blocks
|
||||
// 2. Empty lines separate blocks
|
||||
// 3. List items start new blocks (optional, but good for logs)
|
||||
|
||||
isHeader := strings.HasPrefix(trimmed, "#")
|
||||
isEmpty := trimmed == ""
|
||||
isList := strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || (len(trimmed) > 3 && trimmed[1] == '.' && trimmed[2] == ' ')
|
||||
|
||||
if isHeader {
|
||||
processBlock() // Flush previous
|
||||
lastHeading = strings.TrimLeft(trimmed, "# ")
|
||||
blockStartLine = currentLineNum
|
||||
currentBlock.WriteString(line + "\n")
|
||||
processBlock() // Headers are their own blocks too
|
||||
continue
|
||||
}
|
||||
|
||||
if isEmpty {
|
||||
processBlock() // Flush previous
|
||||
blockStartLine = currentLineNum + 1
|
||||
continue
|
||||
}
|
||||
|
||||
if isList {
|
||||
processBlock() // Flush previous (treat list items as atomic for better granularity)
|
||||
blockStartLine = currentLineNum
|
||||
}
|
||||
|
||||
if currentBlock.Len() == 0 {
|
||||
blockStartLine = currentLineNum
|
||||
}
|
||||
currentBlock.WriteString(line + "\n")
|
||||
}
|
||||
|
||||
processBlock() // Flush last block
|
||||
return results, nil
|
||||
}
|
||||
87
pkg/tools/message.go
Normal file
87
pkg/tools/message.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type SendCallback func(channel, chatID, content string) error
|
||||
|
||||
type MessageTool struct {
|
||||
sendCallback SendCallback
|
||||
defaultChannel string
|
||||
defaultChatID string
|
||||
}
|
||||
|
||||
func NewMessageTool() *MessageTool {
|
||||
return &MessageTool{}
|
||||
}
|
||||
|
||||
func (t *MessageTool) Name() string {
|
||||
return "message"
|
||||
}
|
||||
|
||||
func (t *MessageTool) Description() string {
|
||||
return "Send a message to user on a chat channel. Use this when you want to communicate something."
|
||||
}
|
||||
|
||||
func (t *MessageTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"content": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The message content to send",
|
||||
},
|
||||
"channel": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Optional: target channel (telegram, whatsapp, etc.)",
|
||||
},
|
||||
"chat_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Optional: target chat/user ID",
|
||||
},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MessageTool) SetContext(channel, chatID string) {
|
||||
t.defaultChannel = channel
|
||||
t.defaultChatID = chatID
|
||||
}
|
||||
|
||||
func (t *MessageTool) SetSendCallback(callback SendCallback) {
|
||||
t.sendCallback = callback
|
||||
}
|
||||
|
||||
func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
content, ok := args["content"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("content is required")
|
||||
}
|
||||
|
||||
channel, _ := args["channel"].(string)
|
||||
chatID, _ := args["chat_id"].(string)
|
||||
|
||||
if channel == "" {
|
||||
channel = t.defaultChannel
|
||||
}
|
||||
if chatID == "" {
|
||||
chatID = t.defaultChatID
|
||||
}
|
||||
|
||||
if channel == "" || chatID == "" {
|
||||
return "Error: No target channel/chat specified", nil
|
||||
}
|
||||
|
||||
if t.sendCallback == nil {
|
||||
return "Error: Message sending not configured", nil
|
||||
}
|
||||
|
||||
if err := t.sendCallback(channel, chatID, content); err != nil {
|
||||
return fmt.Sprintf("Error sending message: %v", err), nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Message sent to %s:%s", channel, chatID), nil
|
||||
}
|
||||
116
pkg/tools/registry.go
Normal file
116
pkg/tools/registry.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.kkkk.dev/DBT/clawgo/pkg/logger"
|
||||
)
|
||||
|
||||
type ToolRegistry struct {
|
||||
tools map[string]Tool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewToolRegistry() *ToolRegistry {
|
||||
return &ToolRegistry{
|
||||
tools: make(map[string]Tool),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ToolRegistry) Register(tool Tool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.tools[tool.Name()] = tool
|
||||
}
|
||||
|
||||
func (r *ToolRegistry) Get(name string) (Tool, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
tool, ok := r.tools[name]
|
||||
return tool, ok
|
||||
}
|
||||
|
||||
func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]interface{}) (string, error) {
|
||||
logger.InfoCF("tool", "Tool execution started",
|
||||
map[string]interface{}{
|
||||
"tool": name,
|
||||
"args": args,
|
||||
})
|
||||
|
||||
tool, ok := r.Get(name)
|
||||
if !ok {
|
||||
logger.ErrorCF("tool", "Tool not found",
|
||||
map[string]interface{}{
|
||||
"tool": name,
|
||||
})
|
||||
return "", fmt.Errorf("tool '%s' not found", name)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result, err := tool.Execute(ctx, args)
|
||||
duration := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("tool", "Tool execution failed",
|
||||
map[string]interface{}{
|
||||
"tool": name,
|
||||
"duration": duration.Milliseconds(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
logger.InfoCF("tool", "Tool execution completed",
|
||||
map[string]interface{}{
|
||||
"tool": name,
|
||||
"duration_ms": duration.Milliseconds(),
|
||||
"result_length": len(result),
|
||||
})
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *ToolRegistry) GetDefinitions() []map[string]interface{} {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
definitions := make([]map[string]interface{}, 0, len(r.tools))
|
||||
for _, tool := range r.tools {
|
||||
definitions = append(definitions, ToolToSchema(tool))
|
||||
}
|
||||
return definitions
|
||||
}
|
||||
|
||||
// List returns a list of all registered tool names.
|
||||
func (r *ToolRegistry) List() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(r.tools))
|
||||
for name := range r.tools {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Count returns the number of registered tools.
|
||||
func (r *ToolRegistry) Count() int {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.tools)
|
||||
}
|
||||
|
||||
// GetSummaries returns human-readable summaries of all registered tools.
|
||||
// Returns a slice of "name - description" strings.
|
||||
func (r *ToolRegistry) GetSummaries() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
summaries := make([]string, 0, len(r.tools))
|
||||
for _, tool := range r.tools {
|
||||
summaries = append(summaries, fmt.Sprintf("- `%s` - %s", tool.Name(), tool.Description()))
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
122
pkg/tools/remind.go
Normal file
122
pkg/tools/remind.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.kkkk.dev/DBT/clawgo/pkg/cron"
|
||||
)
|
||||
|
||||
type RemindTool struct {
|
||||
cs *cron.CronService
|
||||
}
|
||||
|
||||
func NewRemindTool(cs *cron.CronService) *RemindTool {
|
||||
return &RemindTool{cs: cs}
|
||||
}
|
||||
|
||||
func (t *RemindTool) Name() string {
|
||||
return "remind"
|
||||
}
|
||||
|
||||
func (t *RemindTool) Description() string {
|
||||
return "Set a reminder for a future time"
|
||||
}
|
||||
|
||||
func (t *RemindTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The reminder message",
|
||||
},
|
||||
"time_expr": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "When to remind (e.g., '10m', '1h', '2026-02-12 10:00')",
|
||||
},
|
||||
},
|
||||
"required": []string{"message", "time_expr"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *RemindTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
if t.cs == nil {
|
||||
return "", fmt.Errorf("cron service not available")
|
||||
}
|
||||
|
||||
message, ok := args["message"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("message is required")
|
||||
}
|
||||
|
||||
timeExpr, ok := args["time_expr"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("time_expr is required")
|
||||
}
|
||||
|
||||
// Try duration first (e.g., "10m", "1h30m")
|
||||
if d, err := time.ParseDuration(timeExpr); err == nil {
|
||||
at := time.Now().Add(d).UnixMilli()
|
||||
schedule := cron.CronSchedule{
|
||||
Kind: "at",
|
||||
AtMS: &at,
|
||||
}
|
||||
job, err := t.cs.AddJob("Reminder", schedule, message, true, "", "") // deliver=true, channel="" means default
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to schedule reminder: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("Reminder set for %s (in %s). Job ID: %s", time.UnixMilli(at).Format(time.RFC1123), d, job.ID), nil
|
||||
}
|
||||
|
||||
// Try absolute date/time formats
|
||||
formats := []string{
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02 15:04:05",
|
||||
"15:04",
|
||||
"15:04:05",
|
||||
}
|
||||
|
||||
var parsedTime time.Time
|
||||
var parseErr error
|
||||
parsed := false
|
||||
|
||||
for _, layout := range formats {
|
||||
if t, err := time.ParseInLocation(layout, timeExpr, time.Local); err == nil {
|
||||
parsedTime = t
|
||||
parsed = true
|
||||
// If format was time-only, use today or tomorrow
|
||||
if layout == "15:04" || layout == "15:04:05" {
|
||||
now := time.Now()
|
||||
// Combine today's date with parsed time
|
||||
combined := time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), t.Second(), 0, time.Local)
|
||||
if combined.Before(now) {
|
||||
// If time passed today, assume tomorrow
|
||||
combined = combined.Add(24 * time.Hour)
|
||||
}
|
||||
parsedTime = combined
|
||||
}
|
||||
break
|
||||
} else {
|
||||
parseErr = err
|
||||
}
|
||||
}
|
||||
|
||||
if !parsed {
|
||||
return "", fmt.Errorf("could not parse time expression '%s': %v", timeExpr, parseErr)
|
||||
}
|
||||
|
||||
at := parsedTime.UnixMilli()
|
||||
schedule := cron.CronSchedule{
|
||||
Kind: "at",
|
||||
AtMS: &at,
|
||||
}
|
||||
|
||||
job, err := t.cs.AddJob("Reminder", schedule, message, true, "", "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to schedule reminder: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Reminder set for %s. Job ID: %s", parsedTime.Format(time.RFC1123), job.ID), nil
|
||||
}
|
||||
202
pkg/tools/shell.go
Normal file
202
pkg/tools/shell.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ExecTool struct {
|
||||
workingDir string
|
||||
timeout time.Duration
|
||||
denyPatterns []*regexp.Regexp
|
||||
allowPatterns []*regexp.Regexp
|
||||
restrictToWorkspace bool
|
||||
}
|
||||
|
||||
func NewExecTool(workingDir string) *ExecTool {
|
||||
denyPatterns := []*regexp.Regexp{
|
||||
regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`),
|
||||
regexp.MustCompile(`\bdel\s+/[fq]\b`),
|
||||
regexp.MustCompile(`\brmdir\s+/s\b`),
|
||||
regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args)
|
||||
regexp.MustCompile(`\bdd\s+if=`),
|
||||
regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null)
|
||||
regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`),
|
||||
regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`),
|
||||
}
|
||||
|
||||
return &ExecTool{
|
||||
workingDir: workingDir,
|
||||
timeout: 60 * time.Second,
|
||||
denyPatterns: denyPatterns,
|
||||
allowPatterns: nil,
|
||||
restrictToWorkspace: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ExecTool) Name() string {
|
||||
return "exec"
|
||||
}
|
||||
|
||||
func (t *ExecTool) Description() string {
|
||||
return "Execute a shell command and return its output. Use with caution."
|
||||
}
|
||||
|
||||
func (t *ExecTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"command": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The shell command to execute",
|
||||
},
|
||||
"working_dir": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Optional working directory for the command",
|
||||
},
|
||||
},
|
||||
"required": []string{"command"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
command, ok := args["command"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("command is required")
|
||||
}
|
||||
|
||||
cwd := t.workingDir
|
||||
if wd, ok := args["working_dir"].(string); ok && wd != "" {
|
||||
cwd = wd
|
||||
}
|
||||
|
||||
if cwd == "" {
|
||||
wd, err := os.Getwd()
|
||||
if err == nil {
|
||||
cwd = wd
|
||||
}
|
||||
}
|
||||
|
||||
if guardError := t.guardCommand(command, cwd); guardError != "" {
|
||||
return fmt.Sprintf("Error: %s", guardError), nil
|
||||
}
|
||||
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, "sh", "-c", command)
|
||||
if cwd != "" {
|
||||
cmd.Dir = cwd
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
output := stdout.String()
|
||||
if stderr.Len() > 0 {
|
||||
output += "\nSTDERR:\n" + stderr.String()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Sprintf("Error: Command timed out after %v", t.timeout), nil
|
||||
}
|
||||
output += fmt.Sprintf("\nExit code: %v", err)
|
||||
}
|
||||
|
||||
if output == "" {
|
||||
output = "(no output)"
|
||||
}
|
||||
|
||||
maxLen := 10000
|
||||
if len(output) > maxLen {
|
||||
output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen)
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||
cmd := strings.TrimSpace(command)
|
||||
lower := strings.ToLower(cmd)
|
||||
|
||||
for _, pattern := range t.denyPatterns {
|
||||
if pattern.MatchString(lower) {
|
||||
return "Command blocked by safety guard (dangerous pattern detected)"
|
||||
}
|
||||
}
|
||||
|
||||
if len(t.allowPatterns) > 0 {
|
||||
allowed := false
|
||||
for _, pattern := range t.allowPatterns {
|
||||
if pattern.MatchString(lower) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return "Command blocked by safety guard (not in allowlist)"
|
||||
}
|
||||
}
|
||||
|
||||
if t.restrictToWorkspace {
|
||||
if strings.Contains(cmd, "..\\") || strings.Contains(cmd, "../") {
|
||||
return "Command blocked by safety guard (path traversal detected)"
|
||||
}
|
||||
|
||||
cwdPath, err := filepath.Abs(cwd)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
pathPattern := regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`)
|
||||
matches := pathPattern.FindAllString(cmd, -1)
|
||||
|
||||
for _, raw := range matches {
|
||||
p, err := filepath.Abs(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(cwdPath, p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(rel, "..") {
|
||||
return "Command blocked by safety guard (path outside working dir)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (t *ExecTool) SetTimeout(timeout time.Duration) {
|
||||
t.timeout = timeout
|
||||
}
|
||||
|
||||
func (t *ExecTool) SetRestrictToWorkspace(restrict bool) {
|
||||
t.restrictToWorkspace = restrict
|
||||
}
|
||||
|
||||
func (t *ExecTool) SetAllowPatterns(patterns []string) error {
|
||||
t.allowPatterns = make([]*regexp.Regexp, 0, len(patterns))
|
||||
for _, p := range patterns {
|
||||
re, err := regexp.Compile(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid allow pattern %q: %w", p, err)
|
||||
}
|
||||
t.allowPatterns = append(t.allowPatterns, re)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
70
pkg/tools/spawn.go
Normal file
70
pkg/tools/spawn.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type SpawnTool struct {
|
||||
manager *SubagentManager
|
||||
originChannel string
|
||||
originChatID string
|
||||
}
|
||||
|
||||
func NewSpawnTool(manager *SubagentManager) *SpawnTool {
|
||||
return &SpawnTool{
|
||||
manager: manager,
|
||||
originChannel: "cli",
|
||||
originChatID: "direct",
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SpawnTool) Name() string {
|
||||
return "spawn"
|
||||
}
|
||||
|
||||
func (t *SpawnTool) Description() string {
|
||||
return "Spawn a subagent to handle a task in the background. Use this for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done."
|
||||
}
|
||||
|
||||
func (t *SpawnTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The task for subagent to complete",
|
||||
},
|
||||
"label": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Optional short label for the task (for display)",
|
||||
},
|
||||
},
|
||||
"required": []string{"task"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SpawnTool) SetContext(channel, chatID string) {
|
||||
t.originChannel = channel
|
||||
t.originChatID = chatID
|
||||
}
|
||||
|
||||
func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
task, ok := args["task"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("task is required")
|
||||
}
|
||||
|
||||
label, _ := args["label"].(string)
|
||||
|
||||
if t.manager == nil {
|
||||
return "Error: Subagent manager not configured", nil
|
||||
}
|
||||
|
||||
result, err := t.manager.Spawn(ctx, task, label, t.originChannel, t.originChatID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to spawn subagent: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
128
pkg/tools/subagent.go
Normal file
128
pkg/tools/subagent.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.kkkk.dev/DBT/clawgo/pkg/bus"
|
||||
"gitea.kkkk.dev/DBT/clawgo/pkg/providers"
|
||||
)
|
||||
|
||||
type SubagentTask struct {
|
||||
ID string
|
||||
Task string
|
||||
Label string
|
||||
OriginChannel string
|
||||
OriginChatID string
|
||||
Status string
|
||||
Result string
|
||||
Created int64
|
||||
}
|
||||
|
||||
type SubagentManager struct {
|
||||
tasks map[string]*SubagentTask
|
||||
mu sync.RWMutex
|
||||
provider providers.LLMProvider
|
||||
bus *bus.MessageBus
|
||||
workspace string
|
||||
nextID int
|
||||
}
|
||||
|
||||
func NewSubagentManager(provider providers.LLMProvider, workspace string, bus *bus.MessageBus) *SubagentManager {
|
||||
return &SubagentManager{
|
||||
tasks: make(map[string]*SubagentTask),
|
||||
provider: provider,
|
||||
bus: bus,
|
||||
workspace: workspace,
|
||||
nextID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel, originChatID string) (string, error) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
taskID := fmt.Sprintf("subagent-%d", sm.nextID)
|
||||
sm.nextID++
|
||||
|
||||
subagentTask := &SubagentTask{
|
||||
ID: taskID,
|
||||
Task: task,
|
||||
Label: label,
|
||||
OriginChannel: originChannel,
|
||||
OriginChatID: originChatID,
|
||||
Status: "running",
|
||||
Created: time.Now().UnixMilli(),
|
||||
}
|
||||
sm.tasks[taskID] = subagentTask
|
||||
|
||||
go sm.runTask(ctx, subagentTask)
|
||||
|
||||
if label != "" {
|
||||
return fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task), nil
|
||||
}
|
||||
return fmt.Sprintf("Spawned subagent for task: %s", task), nil
|
||||
}
|
||||
|
||||
func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) {
|
||||
task.Status = "running"
|
||||
task.Created = time.Now().UnixMilli()
|
||||
|
||||
messages := []providers.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "You are a subagent. Complete the given task independently and report the result.",
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: task.Task,
|
||||
},
|
||||
}
|
||||
|
||||
response, err := sm.provider.Chat(ctx, messages, nil, sm.provider.GetDefaultModel(), map[string]interface{}{
|
||||
"max_tokens": 4096,
|
||||
})
|
||||
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
task.Status = "failed"
|
||||
task.Result = fmt.Sprintf("Error: %v", err)
|
||||
} else {
|
||||
task.Status = "completed"
|
||||
task.Result = response.Content
|
||||
}
|
||||
|
||||
// Send announce message back to main agent
|
||||
if sm.bus != nil {
|
||||
announceContent := fmt.Sprintf("Task '%s' completed.\n\nResult:\n%s", task.Label, task.Result)
|
||||
sm.bus.PublishInbound(bus.InboundMessage{
|
||||
Channel: "system",
|
||||
SenderID: fmt.Sprintf("subagent:%s", task.ID),
|
||||
// Format: "original_channel:original_chat_id" for routing back
|
||||
ChatID: fmt.Sprintf("%s:%s", task.OriginChannel, task.OriginChatID),
|
||||
Content: announceContent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
task, ok := sm.tasks[taskID]
|
||||
return task, ok
|
||||
}
|
||||
|
||||
func (sm *SubagentManager) ListTasks() []*SubagentTask {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
|
||||
tasks := make([]*SubagentTask, 0, len(sm.tasks))
|
||||
for _, task := range sm.tasks {
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
85
pkg/tools/system.go
Normal file
85
pkg/tools/system.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type SystemInfoTool struct {}
|
||||
|
||||
func NewSystemInfoTool() *SystemInfoTool {
|
||||
return &SystemInfoTool{}
|
||||
}
|
||||
|
||||
func (t *SystemInfoTool) Name() string {
|
||||
return "system_info"
|
||||
}
|
||||
|
||||
func (t *SystemInfoTool) Description() string {
|
||||
return "Get current system status (CPU, RAM, Disk)."
|
||||
}
|
||||
|
||||
func (t *SystemInfoTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SystemInfoTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
info := fmt.Sprintf("System Info:\n")
|
||||
info += fmt.Sprintf("OS: %s %s\n", runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
// Load Average
|
||||
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||
info += fmt.Sprintf("Load Avg: %s", string(data))
|
||||
} else {
|
||||
info += "Load Avg: N/A\n"
|
||||
}
|
||||
|
||||
// RAM from /proc/meminfo
|
||||
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
var total, free, available uint64
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "MemTotal:") {
|
||||
fmt.Sscanf(line, "MemTotal: %d kB", &total)
|
||||
} else if strings.HasPrefix(line, "MemFree:") {
|
||||
fmt.Sscanf(line, "MemFree: %d kB", &free)
|
||||
} else if strings.HasPrefix(line, "MemAvailable:") {
|
||||
fmt.Sscanf(line, "MemAvailable: %d kB", &available)
|
||||
}
|
||||
}
|
||||
if total > 0 {
|
||||
// fallback if Available not present (older kernels)
|
||||
if available == 0 {
|
||||
available = free // very rough approximation
|
||||
}
|
||||
used := total - available
|
||||
info += fmt.Sprintf("RAM: Used %.2f GB / Total %.2f GB (%.2f%%)\n",
|
||||
float64(used)/1024/1024, float64(total)/1024/1024, float64(used)/float64(total)*100)
|
||||
}
|
||||
} else {
|
||||
info += "RAM: N/A\n"
|
||||
}
|
||||
|
||||
// Disk usage for /
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs("/", &stat); err == nil {
|
||||
// Cast to uint64 to avoid overflow/type mismatch
|
||||
bsize := uint64(stat.Bsize)
|
||||
total := stat.Blocks * bsize
|
||||
free := stat.Bfree * bsize
|
||||
used := total - free
|
||||
info += fmt.Sprintf("Disk (/): Used %.2f GB / Total %.2f GB (%.2f%%)\n",
|
||||
float64(used)/1024/1024/1024, float64(total)/1024/1024/1024, float64(used)/float64(total)*100)
|
||||
} else {
|
||||
info += "Disk: N/A\n"
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
52
pkg/tools/types.go
Normal file
52
pkg/tools/types.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package tools
|
||||
|
||||
import "context"
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function *FunctionCall `json:"function,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments map[string]interface{} `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
type FunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
type LLMResponse struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
Usage *UsageInfo `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type UsageInfo struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
type LLMProvider interface {
|
||||
Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error)
|
||||
GetDefaultModel() string
|
||||
}
|
||||
|
||||
type ToolDefinition struct {
|
||||
Type string `json:"type"`
|
||||
Function ToolFunctionDefinition `json:"function"`
|
||||
}
|
||||
|
||||
type ToolFunctionDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
}
|
||||
346
pkg/tools/web.go
Normal file
346
pkg/tools/web.go
Normal file
@@ -0,0 +1,346 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
userAgent = "Mozilla/5.0 (compatible; clawgo/1.0)"
|
||||
)
|
||||
|
||||
type WebSearchTool struct {
|
||||
apiKey string
|
||||
maxResults int
|
||||
}
|
||||
|
||||
func NewWebSearchTool(apiKey string, maxResults int) *WebSearchTool {
|
||||
if maxResults <= 0 || maxResults > 10 {
|
||||
maxResults = 5
|
||||
}
|
||||
return &WebSearchTool{
|
||||
apiKey: apiKey,
|
||||
maxResults: maxResults,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WebSearchTool) Name() string {
|
||||
return "web_search"
|
||||
}
|
||||
|
||||
func (t *WebSearchTool) Description() string {
|
||||
return "Search the web for current information. Returns titles, URLs, and snippets from search results."
|
||||
}
|
||||
|
||||
func (t *WebSearchTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"query": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Search query",
|
||||
},
|
||||
"count": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "Number of results (1-10)",
|
||||
"minimum": 1.0,
|
||||
"maximum": 10.0,
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
if t.apiKey == "" {
|
||||
return "Error: BRAVE_API_KEY not configured", nil
|
||||
}
|
||||
|
||||
query, ok := args["query"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("query is required")
|
||||
}
|
||||
|
||||
count := t.maxResults
|
||||
if c, ok := args["count"].(float64); ok {
|
||||
if int(c) > 0 && int(c) <= 10 {
|
||||
count = int(c)
|
||||
}
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
|
||||
url.QueryEscape(query), count)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Subscription-Token", t.apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var searchResp struct {
|
||||
Web struct {
|
||||
Results []struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
} `json:"results"`
|
||||
} `json:"web"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
results := searchResp.Web.Results
|
||||
if len(results) == 0 {
|
||||
return fmt.Sprintf("No results for: %s", query), nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("Results for: %s", query))
|
||||
for i, item := range results {
|
||||
if i >= count {
|
||||
break
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
|
||||
if item.Description != "" {
|
||||
lines = append(lines, fmt.Sprintf(" %s", item.Description))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
type WebFetchTool struct {
|
||||
maxChars int
|
||||
}
|
||||
|
||||
func NewWebFetchTool(maxChars int) *WebFetchTool {
|
||||
if maxChars <= 0 {
|
||||
maxChars = 50000
|
||||
}
|
||||
return &WebFetchTool{
|
||||
maxChars: maxChars,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WebFetchTool) Name() string {
|
||||
return "web_fetch"
|
||||
}
|
||||
|
||||
func (t *WebFetchTool) Description() string {
|
||||
return "Fetch a URL and extract readable content (HTML to Markdown). Preserves structure (headers, links, code blocks) for better reading."
|
||||
}
|
||||
|
||||
func (t *WebFetchTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "URL to fetch",
|
||||
},
|
||||
"maxChars": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "Maximum characters to extract",
|
||||
"minimum": 100.0,
|
||||
},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
urlStr, ok := args["url"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("url is required")
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return "", fmt.Errorf("only http/https URLs are allowed")
|
||||
}
|
||||
|
||||
if parsedURL.Host == "" {
|
||||
return "", fmt.Errorf("missing domain in URL")
|
||||
}
|
||||
|
||||
maxChars := t.maxChars
|
||||
if mc, ok := args["maxChars"].(float64); ok {
|
||||
if int(mc) > 100 {
|
||||
maxChars = int(mc)
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableCompression: false,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("stopped after 5 redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
|
||||
var text, extractor string
|
||||
|
||||
if strings.Contains(contentType, "application/json") {
|
||||
var jsonData interface{}
|
||||
if err := json.Unmarshal(body, &jsonData); err == nil {
|
||||
formatted, _ := json.MarshalIndent(jsonData, "", " ")
|
||||
text = string(formatted)
|
||||
extractor = "json"
|
||||
} else {
|
||||
text = string(body)
|
||||
extractor = "raw"
|
||||
}
|
||||
} else if strings.Contains(contentType, "text/html") || len(body) > 0 &&
|
||||
(strings.HasPrefix(string(body), "<!DOCTYPE") || strings.HasPrefix(strings.ToLower(string(body)), "<html")) {
|
||||
text = t.extractMarkdown(string(body))
|
||||
extractor = "html-to-markdown"
|
||||
} else {
|
||||
text = string(body)
|
||||
extractor = "raw"
|
||||
}
|
||||
|
||||
truncated := len(text) > maxChars
|
||||
if truncated {
|
||||
text = text[:maxChars]
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"url": urlStr,
|
||||
"status": resp.StatusCode,
|
||||
"extractor": extractor,
|
||||
"truncated": truncated,
|
||||
"length": len(text),
|
||||
"text": text,
|
||||
}
|
||||
|
||||
resultJSON, _ := json.MarshalIndent(result, "", " ")
|
||||
return string(resultJSON), nil
|
||||
}
|
||||
|
||||
// extractMarkdown converts HTML to simplified Markdown using Regex.
|
||||
// It's not perfect but much better than stripping everything.
|
||||
func (t *WebFetchTool) extractMarkdown(html string) string {
|
||||
// 1. Remove Scripts and Styles
|
||||
re := regexp.MustCompile(`(?i)<script[\s\S]*?</script>`)
|
||||
html = re.ReplaceAllLiteralString(html, "")
|
||||
re = regexp.MustCompile(`(?i)<style[\s\S]*?</style>`)
|
||||
html = re.ReplaceAllLiteralString(html, "")
|
||||
re = regexp.MustCompile(`(?i)<!--[\s\S]*?-->`)
|
||||
html = re.ReplaceAllLiteralString(html, "")
|
||||
|
||||
// 2. Pre-process block elements to ensure newlines
|
||||
// Replace </div>, </p>, </h1> etc. with newlines
|
||||
re = regexp.MustCompile(`(?i)</(div|p|h[1-6]|li|ul|ol|table|tr)>`)
|
||||
html = re.ReplaceAllString(html, "\n$0")
|
||||
|
||||
// 3. Convert Headers
|
||||
re = regexp.MustCompile(`(?i)<h1[^>]*>(.*?)</h1>`)
|
||||
html = re.ReplaceAllString(html, "\n# $1\n")
|
||||
re = regexp.MustCompile(`(?i)<h2[^>]*>(.*?)</h2>`)
|
||||
html = re.ReplaceAllString(html, "\n## $1\n")
|
||||
re = regexp.MustCompile(`(?i)<h3[^>]*>(.*?)</h3>`)
|
||||
html = re.ReplaceAllString(html, "\n### $1\n")
|
||||
re = regexp.MustCompile(`(?i)<h[4-6][^>]*>(.*?)<.*?>`)
|
||||
html = re.ReplaceAllString(html, "\n#### $1\n")
|
||||
|
||||
// 4. Convert Links: <a href="url">text</a> -> [text](url)
|
||||
re = regexp.MustCompile(`(?i)<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
|
||||
html = re.ReplaceAllString(html, "[$2]($1)")
|
||||
|
||||
// 5. Convert Images: <img src="url" alt="text"> -> 
|
||||
re = regexp.MustCompile(`(?i)<img[^>]+src="([^"]+)"[^>]*alt="([^"]*)"[^>]*>`)
|
||||
html = re.ReplaceAllString(html, "")
|
||||
|
||||
// 6. Convert Bold/Italic
|
||||
re = regexp.MustCompile(`(?i)<(b|strong)>(.*?)</\1>`)
|
||||
html = re.ReplaceAllString(html, "**$2**")
|
||||
re = regexp.MustCompile(`(?i)<(i|em)>(.*?)</\1>`)
|
||||
html = re.ReplaceAllString(html, "*$2*")
|
||||
|
||||
// 7. Convert Code Blocks
|
||||
re = regexp.MustCompile(`(?i)<pre[^>]*><code[^>]*>([\s\S]*?)</code></pre>`)
|
||||
html = re.ReplaceAllString(html, "\n```\n$1\n```\n")
|
||||
re = regexp.MustCompile(`(?i)<code[^>]*>(.*?)</code>`)
|
||||
html = re.ReplaceAllString(html, "`$1`")
|
||||
|
||||
// 8. Convert Lists
|
||||
re = regexp.MustCompile(`(?i)<li[^>]*>(.*?)</li>`)
|
||||
html = re.ReplaceAllString(html, "- $1\n")
|
||||
|
||||
// 9. Strip remaining tags
|
||||
re = regexp.MustCompile(`<[^>]+>`)
|
||||
html = re.ReplaceAllLiteralString(html, "")
|
||||
|
||||
// 10. Decode HTML Entities (Basic ones)
|
||||
html = strings.ReplaceAll(html, " ", " ")
|
||||
html = strings.ReplaceAll(html, "&", "&")
|
||||
html = strings.ReplaceAll(html, "<", "<")
|
||||
html = strings.ReplaceAll(html, ">", ">")
|
||||
html = strings.ReplaceAll(html, """, "\"")
|
||||
html = strings.ReplaceAll(html, "'", "'")
|
||||
|
||||
// 11. Cleanup Whitespace
|
||||
// Collapse multiple spaces
|
||||
re = regexp.MustCompile(`[ \t]+`)
|
||||
html = re.ReplaceAllLiteralString(html, " ")
|
||||
// Collapse multiple newlines (max 2)
|
||||
re = regexp.MustCompile(`\n{3,}`)
|
||||
html = re.ReplaceAllLiteralString(html, "\n\n")
|
||||
|
||||
return strings.TrimSpace(html)
|
||||
}
|
||||
Reference in New Issue
Block a user