502 lines
16 KiB
Go
502 lines
16 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"strings"
|
|
|
|
"chat-switchboard/knowledge"
|
|
"chat-switchboard/models"
|
|
"chat-switchboard/store"
|
|
"chat-switchboard/workspace"
|
|
)
|
|
|
|
// ── Late Registration ────────────────────────
|
|
// Workspace tools need the workspace.FS and store for execution.
|
|
// Registered from main.go after workspace FS init.
|
|
|
|
// RegisterWorkspaceTools registers all workspace tools with captured dependencies.
|
|
func RegisterWorkspaceTools(stores store.Stores, wfs *workspace.FS) {
|
|
Register(&workspaceLsTool{stores: stores, wfs: wfs})
|
|
Register(&workspaceReadTool{stores: stores, wfs: wfs})
|
|
Register(&workspaceWriteTool{stores: stores, wfs: wfs})
|
|
Register(&workspaceRmTool{stores: stores, wfs: wfs})
|
|
Register(&workspaceMvTool{stores: stores, wfs: wfs})
|
|
Register(&workspacePatchTool{stores: stores, wfs: wfs})
|
|
}
|
|
|
|
// RegisterWorkspaceSearchTool registers the semantic search tool separately
|
|
// because it requires the embedder (which may not be configured).
|
|
func RegisterWorkspaceSearchTool(stores store.Stores, embedder *knowledge.Embedder) {
|
|
Register(&workspaceSearchTool{stores: stores, embedder: embedder})
|
|
}
|
|
|
|
// ── Shared ─────────────────────────────────
|
|
|
|
// loadWorkspace resolves the workspace from the execution context.
|
|
func loadWorkspace(ctx context.Context, stores store.Stores, execCtx ExecutionContext) (*models.Workspace, error) {
|
|
if execCtx.WorkspaceID == "" {
|
|
return nil, fmt.Errorf("no workspace bound to this channel")
|
|
}
|
|
w, err := stores.Workspaces.GetByID(ctx, execCtx.WorkspaceID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("workspace not found: %w", err)
|
|
}
|
|
return w, nil
|
|
}
|
|
|
|
const (
|
|
// maxReadBytes is the soft limit for workspace_read (50 KB).
|
|
maxReadBytes = 50 * 1024
|
|
// maxPatchFileBytes is the limit for files that can be patched (1 MB).
|
|
maxPatchFileBytes = 1024 * 1024
|
|
)
|
|
|
|
// ═══════════════════════════════════════════
|
|
// workspace_ls
|
|
// ═══════════════════════════════════════════
|
|
|
|
type workspaceLsTool struct {
|
|
workspaceToolBase
|
|
stores store.Stores
|
|
wfs *workspace.FS
|
|
}
|
|
|
|
func (t *workspaceLsTool) Definition() ToolDef {
|
|
return ToolDef{
|
|
Name: "workspace_ls",
|
|
DisplayName: "List Files",
|
|
Description: "List files and directories in the workspace. Returns name, type, size, and content type for each entry. Use with path=\"\" or path=\".\" to list root.",
|
|
Category: "workspace",
|
|
Parameters: JSONSchema(map[string]interface{}{
|
|
"path": Prop("string", "Directory path to list (empty or \".\" for root)"),
|
|
"recursive": Prop("boolean", "If true, list all files recursively. Default false."),
|
|
}, nil),
|
|
}
|
|
}
|
|
|
|
func (t *workspaceLsTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
|
var args struct {
|
|
Path string `json:"path"`
|
|
Recursive bool `json:"recursive"`
|
|
}
|
|
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
|
return "", fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
|
|
w, err := loadWorkspace(ctx, t.stores, execCtx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
entries, err := t.wfs.ListDir(ctx, w, args.Path, args.Recursive)
|
|
if err != nil {
|
|
return "", fmt.Errorf("list failed: %w", err)
|
|
}
|
|
|
|
type entry struct {
|
|
Path string `json:"path"`
|
|
IsDir bool `json:"is_directory"`
|
|
ContentType string `json:"content_type,omitempty"`
|
|
SizeBytes int64 `json:"size_bytes,omitempty"`
|
|
}
|
|
|
|
result := make([]entry, 0, len(entries))
|
|
for _, e := range entries {
|
|
result = append(result, entry{
|
|
Path: e.Path,
|
|
IsDir: e.IsDirectory,
|
|
ContentType: e.ContentType,
|
|
SizeBytes: e.SizeBytes,
|
|
})
|
|
}
|
|
|
|
b, _ := json.Marshal(map[string]interface{}{
|
|
"path": args.Path,
|
|
"entries": result,
|
|
"count": len(result),
|
|
})
|
|
return string(b), nil
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// workspace_read
|
|
// ═══════════════════════════════════════════
|
|
|
|
type workspaceReadTool struct {
|
|
workspaceToolBase
|
|
stores store.Stores
|
|
wfs *workspace.FS
|
|
}
|
|
|
|
func (t *workspaceReadTool) Definition() ToolDef {
|
|
return ToolDef{
|
|
Name: "workspace_read",
|
|
DisplayName: "Read File",
|
|
Description: "Read the contents of a file in the workspace. Text files are returned as content; binary files return a summary. Large files are truncated at ~50KB with an indicator.",
|
|
Category: "workspace",
|
|
Parameters: JSONSchema(map[string]interface{}{
|
|
"path": Prop("string", "File path relative to workspace root"),
|
|
}, []string{"path"}),
|
|
}
|
|
}
|
|
|
|
func (t *workspaceReadTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
|
var args struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
|
return "", fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
if args.Path == "" {
|
|
return "", fmt.Errorf("path is required")
|
|
}
|
|
|
|
w, err := loadWorkspace(ctx, t.stores, execCtx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Stat first for content type and directory check
|
|
info, err := t.wfs.Stat(ctx, w, args.Path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("file not found: %w", err)
|
|
}
|
|
if info.IsDirectory {
|
|
return "", fmt.Errorf("cannot read a directory; use workspace_ls instead")
|
|
}
|
|
|
|
// Binary detection
|
|
if !isTextContentType(info.ContentType) {
|
|
b, _ := json.Marshal(map[string]interface{}{
|
|
"path": args.Path,
|
|
"content_type": info.ContentType,
|
|
"size_bytes": info.SizeBytes,
|
|
"binary": true,
|
|
"message": fmt.Sprintf("Binary file (%s), %d bytes", info.ContentType, info.SizeBytes),
|
|
})
|
|
return string(b), nil
|
|
}
|
|
|
|
// Read content
|
|
r, size, err := t.wfs.ReadFile(ctx, w, args.Path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read failed: %w", err)
|
|
}
|
|
defer r.Close()
|
|
|
|
// Read with truncation guard
|
|
buf := make([]byte, maxReadBytes+1)
|
|
n, readErr := io.ReadFull(r, buf)
|
|
if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF {
|
|
return "", fmt.Errorf("read error: %w", readErr)
|
|
}
|
|
|
|
truncated := n > maxReadBytes
|
|
if truncated {
|
|
n = maxReadBytes
|
|
}
|
|
|
|
resp := map[string]interface{}{
|
|
"path": args.Path,
|
|
"content": string(buf[:n]),
|
|
"content_type": info.ContentType,
|
|
"size_bytes": size,
|
|
}
|
|
if truncated {
|
|
resp["truncated"] = true
|
|
resp["message"] = fmt.Sprintf("File truncated at %d bytes (total: %d bytes)", maxReadBytes, size)
|
|
}
|
|
|
|
b, _ := json.Marshal(resp)
|
|
return string(b), nil
|
|
}
|
|
|
|
// isTextContentType returns true for text/* and known source code types.
|
|
func isTextContentType(ct string) bool {
|
|
if strings.HasPrefix(ct, "text/") {
|
|
return true
|
|
}
|
|
switch ct {
|
|
case "application/json", "application/xml", "application/javascript",
|
|
"application/x-yaml", "application/toml", "application/x-sh",
|
|
"application/sql", "application/graphql":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// workspace_write
|
|
// ═══════════════════════════════════════════
|
|
|
|
type workspaceWriteTool struct {
|
|
workspaceToolBase
|
|
stores store.Stores
|
|
wfs *workspace.FS
|
|
}
|
|
|
|
func (t *workspaceWriteTool) Definition() ToolDef {
|
|
return ToolDef{
|
|
Name: "workspace_write",
|
|
DisplayName: "Write File",
|
|
Description: "Create or overwrite a file in the workspace. Parent directories are created automatically. Returns the path and size of the written file.",
|
|
Category: "workspace",
|
|
Parameters: JSONSchema(map[string]interface{}{
|
|
"path": Prop("string", "File path relative to workspace root"),
|
|
"content": Prop("string", "File content to write"),
|
|
}, []string{"path", "content"}),
|
|
}
|
|
}
|
|
|
|
func (t *workspaceWriteTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
|
var args struct {
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
}
|
|
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
|
return "", fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
if args.Path == "" {
|
|
return "", fmt.Errorf("path is required")
|
|
}
|
|
|
|
w, err := loadWorkspace(ctx, t.stores, execCtx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := t.wfs.WriteFile(ctx, w, args.Path, strings.NewReader(args.Content), int64(len(args.Content))); err != nil {
|
|
return "", fmt.Errorf("write failed: %w", err)
|
|
}
|
|
|
|
size := len(args.Content)
|
|
log.Printf("📝 workspace_write: %s (%d bytes)", args.Path, size)
|
|
|
|
b, _ := json.Marshal(map[string]interface{}{
|
|
"path": args.Path,
|
|
"size_bytes": size,
|
|
"message": fmt.Sprintf("Written %d bytes to %s", size, args.Path),
|
|
})
|
|
return string(b), nil
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// workspace_rm
|
|
// ═══════════════════════════════════════════
|
|
|
|
type workspaceRmTool struct {
|
|
workspaceToolBase
|
|
stores store.Stores
|
|
wfs *workspace.FS
|
|
}
|
|
|
|
func (t *workspaceRmTool) Definition() ToolDef {
|
|
return ToolDef{
|
|
Name: "workspace_rm",
|
|
DisplayName: "Delete File",
|
|
Description: "Delete a file or directory from the workspace. For non-empty directories, set recursive=true. This cannot be undone.",
|
|
Category: "workspace",
|
|
Parameters: JSONSchema(map[string]interface{}{
|
|
"path": Prop("string", "File or directory path to delete"),
|
|
"recursive": Prop("boolean", "Required for non-empty directories. Default false."),
|
|
}, []string{"path"}),
|
|
}
|
|
}
|
|
|
|
func (t *workspaceRmTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
|
var args struct {
|
|
Path string `json:"path"`
|
|
Recursive bool `json:"recursive"`
|
|
}
|
|
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
|
return "", fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
if args.Path == "" {
|
|
return "", fmt.Errorf("path is required")
|
|
}
|
|
|
|
w, err := loadWorkspace(ctx, t.stores, execCtx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := t.wfs.DeleteFile(ctx, w, args.Path, args.Recursive); err != nil {
|
|
return "", fmt.Errorf("delete failed: %w", err)
|
|
}
|
|
|
|
log.Printf("🗑️ workspace_rm: %s (recursive=%v)", args.Path, args.Recursive)
|
|
|
|
b, _ := json.Marshal(map[string]interface{}{
|
|
"path": args.Path,
|
|
"message": fmt.Sprintf("Deleted %s", args.Path),
|
|
})
|
|
return string(b), nil
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// workspace_mv
|
|
// ═══════════════════════════════════════════
|
|
|
|
type workspaceMvTool struct {
|
|
workspaceToolBase
|
|
stores store.Stores
|
|
wfs *workspace.FS
|
|
}
|
|
|
|
func (t *workspaceMvTool) Definition() ToolDef {
|
|
return ToolDef{
|
|
Name: "workspace_mv",
|
|
DisplayName: "Move/Rename File",
|
|
Description: "Move or rename a file or directory within the workspace. Destination parent directories are created automatically.",
|
|
Category: "workspace",
|
|
Parameters: JSONSchema(map[string]interface{}{
|
|
"source": Prop("string", "Current file path"),
|
|
"destination": Prop("string", "New file path"),
|
|
}, []string{"source", "destination"}),
|
|
}
|
|
}
|
|
|
|
func (t *workspaceMvTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
|
var args struct {
|
|
Source string `json:"source"`
|
|
Destination string `json:"destination"`
|
|
}
|
|
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
|
return "", fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
if args.Source == "" || args.Destination == "" {
|
|
return "", fmt.Errorf("source and destination are required")
|
|
}
|
|
|
|
w, err := loadWorkspace(ctx, t.stores, execCtx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := t.wfs.MoveFile(ctx, w, args.Source, args.Destination); err != nil {
|
|
return "", fmt.Errorf("move failed: %w", err)
|
|
}
|
|
|
|
log.Printf("📦 workspace_mv: %s → %s", args.Source, args.Destination)
|
|
|
|
b, _ := json.Marshal(map[string]interface{}{
|
|
"source": args.Source,
|
|
"destination": args.Destination,
|
|
"message": fmt.Sprintf("Moved %s → %s", args.Source, args.Destination),
|
|
})
|
|
return string(b), nil
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// workspace_patch
|
|
// ═══════════════════════════════════════════
|
|
|
|
type workspacePatchTool struct {
|
|
workspaceToolBase
|
|
stores store.Stores
|
|
wfs *workspace.FS
|
|
}
|
|
|
|
func (t *workspacePatchTool) Definition() ToolDef {
|
|
return ToolDef{
|
|
Name: "workspace_patch",
|
|
DisplayName: "Patch File",
|
|
Description: "Apply find-and-replace operations to a text file. Each operation's 'find' string must match exactly once in the file. Operations are applied sequentially. Use empty 'replace' to delete matched text.",
|
|
Category: "workspace",
|
|
Parameters: JSONSchema(map[string]interface{}{
|
|
"path": Prop("string", "File path to patch"),
|
|
"operations": map[string]interface{}{
|
|
"type": "array",
|
|
"description": "List of find/replace operations to apply sequentially",
|
|
"items": map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"find": Prop("string", "Exact string to find (must match exactly once)"),
|
|
"replace": Prop("string", "String to replace with (empty to delete)"),
|
|
},
|
|
"required": []string{"find", "replace"},
|
|
},
|
|
},
|
|
}, []string{"path", "operations"}),
|
|
}
|
|
}
|
|
|
|
func (t *workspacePatchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
|
var args struct {
|
|
Path string `json:"path"`
|
|
Operations []struct {
|
|
Find string `json:"find"`
|
|
Replace string `json:"replace"`
|
|
} `json:"operations"`
|
|
}
|
|
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
|
return "", fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
if args.Path == "" {
|
|
return "", fmt.Errorf("path is required")
|
|
}
|
|
if len(args.Operations) == 0 {
|
|
return "", fmt.Errorf("at least one operation is required")
|
|
}
|
|
|
|
w, err := loadWorkspace(ctx, t.stores, execCtx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Read current content
|
|
r, size, err := t.wfs.ReadFile(ctx, w, args.Path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read failed: %w", err)
|
|
}
|
|
defer r.Close()
|
|
|
|
if size > maxPatchFileBytes {
|
|
return "", fmt.Errorf("file too large for patching (%d bytes, max %d)", size, maxPatchFileBytes)
|
|
}
|
|
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read error: %w", err)
|
|
}
|
|
content := string(data)
|
|
|
|
// Apply operations sequentially
|
|
applied := make([]string, 0, len(args.Operations))
|
|
for i, op := range args.Operations {
|
|
if op.Find == "" {
|
|
return "", fmt.Errorf("operation %d: find string is empty", i+1)
|
|
}
|
|
|
|
count := strings.Count(content, op.Find)
|
|
if count == 0 {
|
|
return "", fmt.Errorf("operation %d: find string not found in file", i+1)
|
|
}
|
|
if count > 1 {
|
|
return "", fmt.Errorf("operation %d: find string matches %d times (must match exactly once)", i+1, count)
|
|
}
|
|
|
|
content = strings.Replace(content, op.Find, op.Replace, 1)
|
|
applied = append(applied, fmt.Sprintf("op %d: replaced %d chars → %d chars", i+1, len(op.Find), len(op.Replace)))
|
|
}
|
|
|
|
// Write back
|
|
if err := t.wfs.WriteFile(ctx, w, args.Path, strings.NewReader(content), int64(len(content))); err != nil {
|
|
return "", fmt.Errorf("write failed: %w", err)
|
|
}
|
|
|
|
log.Printf("🔧 workspace_patch: %s (%d ops, %d bytes)", args.Path, len(args.Operations), len(content))
|
|
|
|
b, _ := json.Marshal(map[string]interface{}{
|
|
"path": args.Path,
|
|
"operations": applied,
|
|
"size_bytes": len(content),
|
|
"message": fmt.Sprintf("Applied %d patch(es) to %s", len(args.Operations), args.Path),
|
|
})
|
|
return string(b), nil
|
|
}
|