Initial commit for ClawGo

This commit is contained in:
DBT
2026-02-12 02:57:27 +00:00
commit 4956b1a014
60 changed files with 9991 additions and 0 deletions

21
pkg/tools/base.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"> -> ![text](url)
re = regexp.MustCompile(`(?i)<img[^>]+src="([^"]+)"[^>]*alt="([^"]*)"[^>]*>`)
html = re.ReplaceAllString(html, "![$2]($1)")
// 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, "&nbsp;", " ")
html = strings.ReplaceAll(html, "&amp;", "&")
html = strings.ReplaceAll(html, "&lt;", "<")
html = strings.ReplaceAll(html, "&gt;", ">")
html = strings.ReplaceAll(html, "&quot;", "\"")
html = strings.ReplaceAll(html, "&#39;", "'")
// 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)
}