Files

1 line
23 KiB
JSON

{"repo": "asmanovlev/smago", "files": {"src/tools.go": {"9bd1815c28a59359e63369545f07551ac2d2dd17": {"t": 1785846937.9087558, "n": 21047, "h": "865241ff5b0ee08e", "c": "package main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n)\n\ntype ToolRegistry struct {\n\tcfg *Config\n\ttools map[string]ToolDef\n\tmcpClients []*MCPClient\n\tfileHashes map[string]uint64\n\tfileData map[string]string\n}\n\ntype ToolDef struct {\n\tName string\n\tDescription string\n\tParameters map[string]any\n\tExecute func(ctx context.Context, args map[string]any) (string, error)\n}\n\nfunc NewToolRegistry(cfg *Config) *ToolRegistry {\n\treturn &ToolRegistry{\n\t\tcfg: cfg,\n\t\ttools: make(map[string]ToolDef),\n\t\tfileHashes: make(map[string]uint64),\n\t\tfileData: make(map[string]string),\n\t}\n}\n\nfunc simpleHash(data []byte) uint64 {\n\tvar h uint64 = 0xcbf29ce484222325\n\tfor _, b := range data {\n\t\th ^= uint64(b)\n\t\th *= 0x100000001b3\n\t}\n\treturn h\n}\n\nfunc (r *ToolRegistry) RecordRead(path string, data []byte) {\n\tr.fileHashes[path] = simpleHash(data)\n\tr.fileData[path] = string(data)\n}\n\nfunc (r *ToolRegistry) WasRead(path string) bool {\n\t_, ok := r.fileHashes[path]\n\treturn ok\n}\n\nfunc (r *ToolRegistry) MarkRead(path string) {\n\tr.fileHashes[path] = 0\n\tr.fileData[path] = \"\"\n}\n\nfunc (r *ToolRegistry) CheckHash(path string) error {\n\tfull := r.resolvePath(path)\n\t_, statErr := os.Stat(full)\n\twasRead := r.WasRead(path)\n\tif statErr == nil && !wasRead {\n\t\treturn fmt.Errorf(\"read_file must be called before write_file/edit_file (file exists, not marked as read)\")\n\t}\n\tif !wasRead {\n\t\treturn nil\n\t}\n\texpectedHash := r.fileHashes[path]\n\tdata, err := os.ReadFile(full)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"read_file must be called before write_file/edit_file: %w\", err)\n\t}\n\tif simpleHash(data) != expectedHash {\n\t\treturn fmt.Errorf(\"file %s was modified externally since last read_file (hash mismatch)\", path)\n\t}\n\treturn nil\n}\n\nfunc (r *ToolRegistry) UpdateHash(path string, data []byte) {\n\tr.fileHashes[path] = simpleHash(data)\n\tr.fileData[path] = string(data)\n}\n\n// ============================================================================\n// registerDefaults — register all built-in tools\n// ============================================================================\n\nfunc (r *ToolRegistry) registerDefaults() {\n\tws := &WebSearchTool{}\n\tr.tools[\"web_search\"] = ws.Definition()\n\tr.tools[\"self_modify\"] = (&SelfModifyTool{cfg: r.cfg}).Definition()\n\n\tif v := r.cfg.Providers[\"opencode-go\"]; v.BaseURL != \"\" || os.Getenv(\"SMAGO_OPENCODE_KEY\") != \"\" {\n\t\tapiKey := v.APIKey\n\t\tif apiKey == \"\" {\n\t\t\tapiKey = os.Getenv(\"SMAGO_OPENCODE_KEY\")\n\t\t}\n\t\tbase := v.BaseURL\n\t\tif base == \"\" {\n\t\t\tbase = \"https://opencode.ai/zen/go/v1\"\n\t\t}\n\t\tvt := &VisionTool{APIKey: apiKey, BaseURL: base, Model: \"mimo-v2.5\", MagickExe: r.cfg.MagickExe}\n\t\tr.tools[\"vision\"] = vt.Definition()\n\t}\n\n\tr.tools[\"terminal\"] = ToolDef{\n\t\tName: \"terminal\", Description: \"Run a shell command. Working dir: \" + r.cfg.DataDir,\n\t\tParameters: map[string]any{\"type\": \"object\", \"properties\": map[string]any{\"command\": map[string]any{\"type\": \"string\"}}, \"required\": []string{\"command\"}},\n\t\tExecute: r.execTerminal,\n\t}\n\tr.tools[\"read_file\"] = ToolDef{\n\t\tName: \"read_file\", Description: \"Read a file from disk.\",\n\t\tParameters: map[string]any{\"type\": \"object\", \"properties\": map[string]any{\"path\": map[string]any{\"type\": \"string\"}}, \"required\": []string{\"path\"}},\n\t\tExecute: r.readFile,\n\t}\n\tr.tools[\"write_file\"] = ToolDef{\n\t\tName: \"write_file\", Description: \"Write a file. Requires read_file first. Fails if file was modified externally.\",\n\t\tParameters: map[string]any{\"type\": \"object\", \"properties\": map[string]any{\"path\": map[string]any{\"type\": \"string\"}, \"content\": map[string]any{\"type\": \"string\"}}, \"required\": []string{\"path\", \"content\"}},\n\t\tExecute: r.writeFile,\n\t}\n\tr.tools[\"edit_file\"] = ToolDef{\n\t\tName: \"edit_file\", Description: \"Edit file by line operations. Requires read_file first. Fails if file changed externally.\",\n\t\tParameters: map[string]any{\"type\": \"object\", \"properties\": map[string]any{\n\t\t\t\"path\": map[string]any{\"type\": \"string\"}, \"action\": map[string]any{\"type\": \"string\", \"enum\": []string{\"replace\", \"delete\", \"insert\"}},\n\t\t\t\"start\": map[string]any{\"type\": \"integer\"}, \"end\": map[string]any{\"type\": \"integer\"},\n\t\t\t\"content\": map[string]any{\"type\": \"string\"},\n\t\t}, \"required\": []string{\"path\", \"action\", \"start\"}},\n\t\tExecute: r.editFile,\n\t}\n\tr.tools[\"list_dir\"] = ToolDef{\n\t\tName: \"list_dir\", Description: \"List files in a directory.\",\n\t\tParameters: map[string]any{\"type\": \"object\", \"properties\": map[string]any{\"path\": map[string]any{\"type\": \"string\"}}},\n\t\tExecute: r.listDir,\n\t}\n\tr.tools[\"delete_file\"] = ToolDef{\n\t\tName: \"delete_file\", Description: \"Delete a file permanently. Use with caution.\",\n\t\tParameters: map[string]any{\"type\": \"object\", \"properties\": map[string]any{\"path\": map[string]any{\"type\": \"string\", \"description\": \"File path to delete\"}}, \"required\": []string{\"path\"}},\n\t\tExecute: r.deleteFile,\n\t}\n\tr.tools[\"compress\"] = ToolDef{Name: \"compress\", Description: \"Compress conversation ranges.\", Parameters: compressSchema, Execute: nil}\n\tr.tools[\"find_files\"] = ToolDef{\n\t\tName: \"find_files\", Description: \"Recursive file search with glob patterns. Searches within working directory.\",\n\t\tParameters: map[string]any{\n\t\t\t\"type\": \"object\",\n\t\t\t\"properties\": map[string]any{\n\t\t\t\t\"pattern\": map[string]any{\"type\": \"string\", \"description\": \"Glob pattern (e.g. *.go, src/**/*.py)\"},\n\t\t\t\t\"max_results\": map[string]any{\"type\": \"integer\", \"description\": \"Max results (default 50)\"},\n\t\t\t},\n\t\t\t\"required\": []string{\"pattern\"},\n\t\t},\n\t\tExecute: r.execFindFiles,\n\t}\n\tr.tools[\"file_info\"] = ToolDef{\n\t\tName: \"file_info\", Description: \"Get file/dir metadata (size, permissions, mod time) without reading content.\",\n\t\tParameters: map[string]any{\"type\": \"object\", \"properties\": map[string]any{\"path\": map[string]any{\"type\": \"string\"}}, \"required\": []string{\"path\"}},\n\t\tExecute: r.execFileInfo,\n\t}\n\tr.tools[\"move_file\"] = ToolDef{\n\t\tName: \"move_file\", Description: \"Rename or move a file or directory.\",\n\t\tParameters: map[string]any{\n\t\t\t\"type\": \"object\", \"properties\": map[string]any{\n\t\t\t\t\"source\": map[string]any{\"type\": \"string\", \"description\": \"Source path\"},\n\t\t\t\t\"dest\": map[string]any{\"type\": \"string\", \"description\": \"Destination path\"},\n\t\t\t},\n\t\t\t\"required\": []string{\"source\", \"dest\"},\n\t\t},\n\t\tExecute: r.execMoveFile,\n\t}\n\tr.tools[\"diff\"] = ToolDef{\n\t\tName: \"diff\", Description: \"Show unified diff between two files, or git diff for a path.\",\n\t\tParameters: map[string]any{\n\t\t\t\"type\": \"object\", \"properties\": map[string]any{\n\t\t\t\t\"path1\": map[string]any{\"type\": \"string\", \"description\": \"First file path (or git path if path2 omitted)\"},\n\t\t\t\t\"path2\": map[string]any{\"type\": \"string\", \"description\": \"Second file path (optional — if omitted shows git diff for path1)\"},\n\t\t\t},\n\t\t\t\"required\": []string{\"path1\"},\n\t\t},\n\t\tExecute: r.execDiff,\n\t}\n\n\tr.registerGrep()\n\tr.connectMCPServers()\n}\n\n// ============================================================================\n// terminal\n// ============================================================================\n\nfunc (r *ToolRegistry) execTerminal(ctx context.Context, args map[string]any) (string, error) {\n\tcmd, _ := args[\"command\"].(string)\n\tif cmd == \"\" {\n\t\treturn \"\", fmt.Errorf(\"command required\")\n\t}\n\trunCtx, cancel := context.WithTimeout(ctx, 30*time.Second)\n\tdefer cancel()\n\tshell := ShellFromContext(ctx)\n\tname, shellArgs := BuildShellCommand(shell, cmd)\n\tc := hiddenCmdContext(runCtx, name, shellArgs...)\n\tc.Dir = r.cfg.DataDir\n\tout, err := c.CombinedOutput()\n\tif err != nil {\n\t\tif ctx.Err() != nil {\n\t\t\treturn \"\", ctx.Err()\n\t\t}\n\t\treturn fmt.Sprintf(\"ERROR: %v\\n%s\", err, string(out)), nil\n\t}\n\treturn strings.TrimSpace(string(out)), nil\n}\n\n// ============================================================================\n// read_file\n// ============================================================================\n\nfunc (r *ToolRegistry) readFile(ctx context.Context, args map[string]any) (string, error) {\n\tp, _ := args[\"path\"].(string)\n\tif p == \"\" {\n\t\treturn \"\", fmt.Errorf(\"path required\")\n\t}\n\tfull := r.resolvePath(p)\n\tdata, err := os.ReadFile(full)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tr.RecordRead(p, data)\n\tif len(data) > 50_000 {\n\t\treturn string(data[:50_000]) + \"\\n...[truncated]...\", nil\n\t}\n\treturn string(data), nil\n}\n\n// ============================================================================\n// write_file\n// ============================================================================\n\nfunc (r *ToolRegistry) writeFile(ctx context.Context, args map[string]any) (string, error) {\n\tp, _ := args[\"path\"].(string)\n\tif p == \"\" {\n\t\treturn \"\", fmt.Errorf(\"path required\")\n\t}\n\tcontent, _ := args[\"content\"].(string)\n\tfull := r.resolvePath(p)\n\tdir := filepath.Dir(full)\n\tif _, err := os.Stat(dir); os.IsNotExist(err) {\n\t\treturn \"\", fmt.Errorf(\"directory does not exist: %s\", dir)\n\t}\n\tif err := r.CheckHash(p); err != nil {\n\t\treturn \"\", err\n\t}\n\tif err := os.WriteFile(full, []byte(content), 0644); err != nil {\n\t\treturn \"\", err\n\t}\n\tr.UpdateHash(p, []byte(content))\n\treturn fmt.Sprintf(\"ok: wrote %d bytes to %s\", len(content), p), nil\n}\n\n// ============================================================================\n// edit_file\n// ============================================================================\n\nfunc (r *ToolRegistry) editFile(ctx context.Context, args map[string]any) (string, error) {\n\tp, _ := args[\"path\"].(string)\n\taction, _ := args[\"action\"].(string)\n\tif p == \"\" || action == \"\" {\n\t\treturn \"\", fmt.Errorf(\"path and action required\")\n\t}\n\tfull := r.resolvePath(p)\n\tif err := r.CheckHash(p); err != nil {\n\t\treturn \"\", err\n\t}\n\tdata, err := os.ReadFile(full)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tlines := strings.Split(string(data), \"\\n\")\n\tnLines := len(lines)\n\tstart := toInt(args[\"start\"])\n\toldLines := make([]string, len(lines))\n\tcopy(oldLines, lines)\n\n\tswitch action {\n\tcase \"replace\":\n\t\tif start < 1 {\n\t\t\treturn \"\", fmt.Errorf(\"replace: start must be >= 1 (1-based)\")\n\t\t}\n\t\tend := toInt(args[\"end\"])\n\t\tif end == 0 {\n\t\t\tend = start\n\t\t}\n\t\tif end < start {\n\t\t\treturn \"\", fmt.Errorf(\"replace: end must be >= start\")\n\t\t}\n\t\tif start > nLines {\n\t\t\treturn \"\", fmt.Errorf(\"replace: start %d > file has %d lines\", start, nLines)\n\t\t}\n\t\tcontent, _ := args[\"content\"].(string)\n\t\tvar result []string\n\t\tresult = append(result, lines[:start-1]...)\n\t\tresult = append(result, strings.Split(content, \"\\n\")...)\n\t\tresult = append(result, lines[end:]...)\n\t\tlines = result\n\n\tcase \"delete\":\n\t\tif start < 1 {\n\t\t\treturn \"\", fmt.Errorf(\"delete: start must be >= 1 (1-based)\")\n\t\t}\n\t\tif start > nLines {\n\t\t\treturn \"\", fmt.Errorf(\"delete: start %d > file has %d lines\", start, nLines)\n\t\t}\n\t\tend := toInt(args[\"end\"])\n\t\tif end == 0 {\n\t\t\tend = start\n\t\t}\n\t\tif end > nLines {\n\t\t\tend = nLines\n\t\t}\n\t\tvar result []string\n\t\tresult = append(result, lines[:start-1]...)\n\t\tresult = append(result, lines[end:]...)\n\t\tlines = result\n\n\tcase \"insert\":\n\t\tif start < 0 {\n\t\t\treturn \"\", fmt.Errorf(\"insert: start must be >= 0 (0-based, insert after line N)\")\n\t\t}\n\t\tif start > nLines {\n\t\t\treturn \"\", fmt.Errorf(\"insert: start %d > file has %d lines\", start, nLines)\n\t\t}\n\t\tcontent, _ := args[\"content\"].(string)\n\t\tvar result []string\n\t\tresult = append(result, lines[:start]...)\n\t\tresult = append(result, strings.Split(content, \"\\n\")...)\n\t\tresult = append(result, lines[start:]...)\n\t\tlines = result\n\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"unknown action: %s\", action)\n\t}\n\n\tnewContent := strings.Join(lines, \"\\n\")\n\tif err := os.WriteFile(full, []byte(newContent), 0644); err != nil {\n\t\treturn \"\", err\n\t}\n\tr.UpdateHash(p, []byte(newContent))\n\treturn editDiffSummary(p, action, toInt(args[\"start\"]), oldLines, lines), nil\n}\n\nfunc editDiffSummary(path, action string, startLine int, oldLines, newLines []string) string {\n\tnOld := len(oldLines)\n\tnNew := len(newLines)\n\tdelta := nNew - nOld\n\tsign := \"+\"\n\tif delta <= 0 {\n\t\tsign = \"\"\n\t}\n\treturn fmt.Sprintf(\"ok: %s on %s line %d (%d->%d lines, %s%d)\", action, path, startLine, nOld, nNew, sign, delta)\n}\n\n// ============================================================================\n// list_dir\n// ============================================================================\n\nfunc (r *ToolRegistry) listDir(ctx context.Context, args map[string]any) (string, error) {\n\tp, _ := args[\"path\"].(string)\n\tif p == \"\" {\n\t\tp = \".\"\n\t}\n\tfull := r.resolvePath(p)\n\tentries, err := os.ReadDir(full)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tvar b strings.Builder\n\tfor _, e := range entries {\n\t\tif e.IsDir() {\n\t\t\tb.WriteString(\"d \")\n\t\t} else {\n\t\t\tb.WriteString(\"f \")\n\t\t}\n\t\tb.WriteString(e.Name())\n\t\tb.WriteString(\"\\n\")\n\t}\n\treturn b.String(), nil\n}\n\n// ============================================================================\n// delete_file\n// ============================================================================\n\nfunc (r *ToolRegistry) deleteFile(ctx context.Context, args map[string]any) (string, error) {\n\tp, _ := args[\"path\"].(string)\n\tif p == \"\" {\n\t\treturn \"\", fmt.Errorf(\"path required\")\n\t}\n\tfull := r.resolvePath(p)\n\tinfo, err := os.Stat(full)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"delete: %w\", err)\n\t}\n\tif info.IsDir() {\n\t\tif err := os.RemoveAll(full); err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"delete dir: %w\", err)\n\t\t}\n\t\treturn fmt.Sprintf(\"ok: deleted directory %s (with contents)\", p), nil\n\t}\n\tif err := os.Remove(full); err != nil {\n\t\treturn \"\", fmt.Errorf(\"delete: %w\", err)\n\t}\n\tdelete(r.fileHashes, p)\n\tdelete(r.fileData, p)\n\treturn fmt.Sprintf(\"ok: deleted file %s\", p), nil\n}\n\n// ============================================================================\n// find_files\n// ============================================================================\n\nfunc (r *ToolRegistry) execFindFiles(ctx context.Context, args map[string]any) (string, error) {\n\tpattern, _ := args[\"pattern\"].(string)\n\tif pattern == \"\" {\n\t\treturn \"\", fmt.Errorf(\"pattern required\")\n\t}\n\tmaxResults := toInt(args[\"max_results\"])\n\tif maxResults == 0 {\n\t\tmaxResults = 50\n\t}\n\tmatches, err := filepath.Glob(filepath.Join(r.cfg.DataDir, pattern))\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"find_files: %w\", err)\n\t}\n\tif len(matches) == 0 {\n\t\treturn fmt.Sprintf(\"no files matching %q\", pattern), nil\n\t}\n\tvar b strings.Builder\n\tcount := 0\n\tfor _, m := range matches {\n\t\tif count >= maxResults {\n\t\t\tfmt.Fprintf(&b, \"\\n... (%d more omitted)\", len(matches)-count)\n\t\t\tbreak\n\t\t}\n\t\trel, _ := filepath.Rel(r.cfg.DataDir, m)\n\t\tif rel == \"\" {\n\t\t\trel = m\n\t\t}\n\t\tinfo, err := os.Stat(m)\n\t\tif err == nil {\n\t\t\tsize := info.Size()\n\t\t\tmod := info.ModTime().Format(\"2006-01-02 15:04\")\n\t\t\tif info.IsDir() {\n\t\t\t\tb.WriteString(\" d \")\n\t\t\t} else {\n\t\t\t\tb.WriteString(\" f \")\n\t\t\t}\n\t\t\tfmt.Fprintf(&b, \"%s %s %d\\n\", mod, rel, size)\n\t\t} else {\n\t\t\tb.WriteString(\" \" + rel + \"\\n\")\n\t\t}\n\t\tcount++\n\t}\n\treturn fmt.Sprintf(\"%d files matching %q:\\n%s\", len(matches), pattern, b.String()), nil\n}\n\n// ============================================================================\n// file_info\n// ============================================================================\n\nfunc (r *ToolRegistry) execFileInfo(ctx context.Context, args map[string]any) (string, error) {\n\tp, _ := args[\"path\"].(string)\n\tif p == \"\" {\n\t\treturn \"\", fmt.Errorf(\"path required\")\n\t}\n\tfull := r.resolvePath(p)\n\tinfo, err := os.Stat(full)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"file_info: %w\", err)\n\t}\n\tvar b strings.Builder\n\tfmt.Fprintf(&b, \"path: %s\\n\", p)\n\tfmt.Fprintf(&b, \"size: %d bytes\\n\", info.Size())\n\tfmt.Fprintf(&b, \"modified: %s\\n\", info.ModTime().Format(\"2006-01-02 15:04:05\"))\n\tfmt.Fprintf(&b, \"mode: %s\\n\", info.Mode().Perm())\n\tif info.IsDir() {\n\t\tb.WriteString(\"type: directory\\n\")\n\t} else {\n\t\tb.WriteString(\"type: file\\n\")\n\t}\n\treturn b.String(), nil\n}\n\n// ============================================================================\n// move_file\n// ============================================================================\n\nfunc (r *ToolRegistry) execMoveFile(ctx context.Context, args map[string]any) (string, error) {\n\tsrc, _ := args[\"source\"].(string)\n\tdst, _ := args[\"dest\"].(string)\n\tif src == \"\" || dst == \"\" {\n\t\treturn \"\", fmt.Errorf(\"source and dest required\")\n\t}\n\tsrcFull := r.resolvePath(src)\n\tdstFull := r.resolvePath(dst)\n\tdstDir := filepath.Dir(dstFull)\n\tif _, err := os.Stat(dstDir); os.IsNotExist(err) {\n\t\treturn \"\", fmt.Errorf(\"destination directory does not exist: %s\", dstDir)\n\t}\n\tif err := os.Rename(srcFull, dstFull); err != nil {\n\t\treturn \"\", fmt.Errorf(\"move: %w\", err)\n\t}\n\tif h, ok := r.fileHashes[src]; ok {\n\t\tr.fileHashes[dst] = h\n\t\tdelete(r.fileHashes, src)\n\t}\n\tif d, ok := r.fileData[src]; ok {\n\t\tr.fileData[dst] = d\n\t\tdelete(r.fileData, src)\n\t}\n\treturn fmt.Sprintf(\"ok: moved %s -> %s\", src, dst), nil\n}\n\n// ============================================================================\n// diff\n// ============================================================================\n\nfunc (r *ToolRegistry) execDiff(ctx context.Context, args map[string]any) (string, error) {\n\tpath1, _ := args[\"path1\"].(string)\n\tpath2, _ := args[\"path2\"].(string)\n\tif path1 == \"\" {\n\t\treturn \"\", fmt.Errorf(\"path1 required\")\n\t}\n\tif path2 == \"\" {\n\t\tfull := r.resolvePath(path1)\n\t\trel, _ := filepath.Rel(r.cfg.DataDir, full)\n\t\tshell := ShellFromContext(ctx)\n\t\tname, shellArgs := BuildShellCommand(shell, fmt.Sprintf(\"git diff -- '%s'\", rel))\n\t\tc := hiddenCmdContext(ctx, name, shellArgs...)\n\t\tc.Dir = r.cfg.DataDir\n\t\tc.Env = append(os.Environ(), \"GIT_PAGER=cat\")\n\t\tout, err := c.CombinedOutput()\n\t\tif err != nil {\n\t\t\treturn fmt.Sprintf(\"git diff output:\\n%s\", string(out)), nil\n\t\t}\n\t\tresult := strings.TrimSpace(string(out))\n\t\tif result == \"\" {\n\t\t\treturn \"no uncommitted changes\", nil\n\t\t}\n\t\treturn result, nil\n\t}\n\tfull1 := r.resolvePath(path1)\n\tfull2 := r.resolvePath(path2)\n\tdata1, err := os.ReadFile(full1)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"diff: %w\", err)\n\t}\n\tdata2, err := os.ReadFile(full2)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"diff: %w\", err)\n\t}\n\tlines1 := strings.Split(string(data1), \"\\n\")\n\tlines2 := strings.Split(string(data2), \"\\n\")\n\tvar b strings.Builder\n\tfmt.Fprintf(&b, \"--- %s\\n+++ %s\\n\", path1, path2)\n\tmaxLen := len(lines1)\n\tif len(lines2) > maxLen {\n\t\tmaxLen = len(lines2)\n\t}\n\tchanged := false\n\tfor i := 0; i < maxLen; i++ {\n\t\tl1 := \"\"\n\t\tl2 := \"\"\n\t\tif i < len(lines1) {\n\t\t\tl1 = lines1[i]\n\t\t}\n\t\tif i < len(lines2) {\n\t\t\tl2 = lines2[i]\n\t\t}\n\t\tif l1 != l2 {\n\t\t\tchanged = true\n\t\t\tif i < len(lines1) {\n\t\t\t\tfmt.Fprintf(&b, \"-%s\\n\", l1)\n\t\t\t}\n\t\t\tif i < len(lines2) {\n\t\t\t\tfmt.Fprintf(&b, \"+%s\\n\", l2)\n\t\t\t}\n\t\t}\n\t}\n\tif !changed {\n\t\tb.WriteString(\"(identical)\\n\")\n\t}\n\treturn b.String(), nil\n}\n\n// ============================================================================\n// helpers\n// ============================================================================\n\nfunc (r *ToolRegistry) resolvePath(p string) string {\n\tif filepath.IsAbs(p) {\n\t\treturn p\n\t}\n\treturn filepath.Join(r.cfg.DataDir, p)\n}\n\nfunc toInt(v any) int {\n\tswitch n := v.(type) {\n\tcase float64:\n\t\treturn int(n)\n\tcase int:\n\t\treturn n\n\tcase json.Number:\n\t\ti, _ := n.Int64()\n\t\treturn int(i)\n\tdefault:\n\t\treturn 0\n\t}\n}\n\nfunc dumpArgs(args map[string]any) string {\n\tb, _ := json.Marshal(args)\n\treturn string(b)\n}\n\n// ============================================================================\n// MCP\n// ============================================================================\n\nfunc (r *ToolRegistry) connectMCPServers() {\n\tif len(r.cfg.MCP) == 0 {\n\t\treturn\n\t}\n\tfor name, cfg := range r.cfg.MCP {\n\t\tif !cfg.Enabled {\n\t\t\tcontinue\n\t\t}\n\t\tlog.Printf(\"mcp: connecting to %s ...\", name)\n\t\tclient, err := NewMCPClient(name, cfg)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"mcp: ✗ %s failed: %v\", name, err)\n\t\t\tcontinue\n\t\t}\n\t\tr.mcpClients = append(r.mcpClients, client)\n\t\ttools, err := client.ListTools()\n\t\tif err != nil {\n\t\t\tlog.Printf(\"mcp: ✗ %s listTools: %v\", name, err)\n\t\t\tcontinue\n\t\t}\n\t\tlog.Printf(\"mcp: ✓ %s — %d tool(s)\", name, len(tools))\n\t\tmaxMcpTools := 10\n\t\tif maxMcpTools > len(tools) {\n\t\t\tmaxMcpTools = len(tools)\n\t\t}\n\t\tfor _, mt := range tools[:maxMcpTools] {\n\t\t\ttoolName := name + \"__\" + mt.Name\n\t\t\tmtCopy := mt\n\t\t\tclientCopy := client\n\t\t\tdesc := mtCopy.Description\n\t\t\tif len(desc) > 150 {\n\t\t\t\tdesc = desc[:150] + \"…\"\n\t\t\t}\n\t\t\tr.tools[toolName] = ToolDef{Name: toolName, Description: desc, Parameters: mtCopy.InputSchema,\n\t\t\t\tExecute: func(ctx context.Context, args map[string]any) (string, error) {\n\t\t\t\t\treturn clientCopy.CallTool(ctx, mtCopy.Name, args)\n\t\t\t\t}}\n\t\t}\n\t}\n}\n\nfunc (r *ToolRegistry) Close() {\n\tfor _, c := range r.mcpClients {\n\t\t_ = c.Close()\n\t}\n\tr.mcpClients = nil\n}\n\nfunc (r *ToolRegistry) Register(name string, def ToolDef) { r.tools[name] = def }\nfunc (r *ToolRegistry) All() []ToolDef {\n\tout := make([]ToolDef, 0, len(r.tools))\n\tfor _, t := range r.tools {\n\t\tout = append(out, t)\n\t}\n\treturn out\n}\nfunc (r *ToolRegistry) Get(name string) (ToolDef, bool) { t, ok := r.tools[name]; return t, ok }\nfunc (r *ToolRegistry) AsLLMTools() []Tool {\n\tvar out []Tool\n\tfor _, t := range r.All() {\n\t\tvar ti Tool\n\t\tti.Type = \"function\"\n\t\tti.Function.Name = t.Name\n\t\tti.Function.Description = t.Description\n\t\tti.Function.Parameters = t.Parameters\n\t\tout = append(out, ti)\n\t}\n\treturn out\n}\n"}}}}