mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
feat: files built-in plugin, doc rewrite, architecture cleanup
- Add files plugin as built-in (internal/plugins/files/) with read/write/edit/ls tools, supporting overwrite/append/insert/create modes and offset/limit segmented reading - Rewrite README.md with core domain separation and three-layer memory highlights - Rewrite docs/OVERVIEW.md with per-subsystem file path references - Rewrite docs/ARCHITECTURE.md (783→~300 lines), merge redundant sections - Clean docs/PLUGIN_DEV.md: remove emoji, simplify SDK examples - Fix provider Model pollution in LuaAdaptedProvider.Chat() - Fix executeToolCall to return actual error vs quiet not-found - Fix plugin.Open path caching with SHA256 temp-path workaround - Add knowledge/homeagent_architecture demo entry - Add config/personal/personal.md identity configuration
This commit is contained in:
@ -407,9 +407,7 @@ func NewLuaAdaptedProvider(cfg BaseConfig, vm *luaVM.VM, adapter string) *LuaAda
|
||||
func (p *LuaAdaptedProvider) Name() string { return p.name }
|
||||
|
||||
func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
|
||||
if req.Model == "" {
|
||||
req.Model = p.cfg.Model
|
||||
}
|
||||
req.Model = p.cfg.Model
|
||||
|
||||
rawReq, _ := json.Marshal(req)
|
||||
|
||||
@ -464,9 +462,7 @@ func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (
|
||||
}
|
||||
|
||||
func (p *LuaAdaptedProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
|
||||
if req.Model == "" {
|
||||
req.Model = p.cfg.Model
|
||||
}
|
||||
req.Model = p.cfg.Model
|
||||
req.Stream = true
|
||||
rawReq, _ := json.Marshal(req)
|
||||
|
||||
@ -585,6 +581,7 @@ func (s *SSEScanner) Text() string { return s.pending }
|
||||
type ProviderManager struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]Provider
|
||||
order []string
|
||||
default_ string
|
||||
}
|
||||
|
||||
@ -598,6 +595,7 @@ func (m *ProviderManager) Register(name string, p Provider) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.providers[name] = p
|
||||
m.order = append(m.order, name)
|
||||
if m.default_ == "" {
|
||||
m.default_ = name
|
||||
}
|
||||
@ -647,13 +645,29 @@ func (m *ProviderManager) QuickChat(ctx context.Context, prompt string) (*Comple
|
||||
func (m *ProviderManager) List() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var names []string
|
||||
for n := range m.providers {
|
||||
names = append(names, n)
|
||||
}
|
||||
names := make([]string, len(m.order))
|
||||
copy(names, m.order)
|
||||
return names
|
||||
}
|
||||
|
||||
func (m *ProviderManager) OrderedProviders() []Provider {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
list := make([]Provider, 0, len(m.order))
|
||||
for _, name := range m.order {
|
||||
if p, ok := m.providers[name]; ok {
|
||||
list = append(list, p)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (m *ProviderManager) ProviderCount() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.providers)
|
||||
}
|
||||
|
||||
type rawToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
|
||||
@ -42,7 +42,6 @@ type Agent struct {
|
||||
systemPrompt string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
maxTurns int
|
||||
|
||||
// 文档记忆(第二层)
|
||||
docStore *document.Store
|
||||
@ -114,7 +113,6 @@ type AgentConfig struct {
|
||||
Indexer *memory.Indexer
|
||||
Skills *skill.Manager
|
||||
Tracker *tracker.Tracker
|
||||
MaxToolTurns int
|
||||
|
||||
DocStore *document.Store
|
||||
Knowledge *knowledge.Store
|
||||
@ -135,9 +133,6 @@ type AgentConfig struct {
|
||||
|
||||
func New(cfg AgentConfig) *Agent {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if cfg.MaxToolTurns <= 0 {
|
||||
cfg.MaxToolTurns = 10
|
||||
}
|
||||
if cfg.DistillInterval <= 0 {
|
||||
cfg.DistillInterval = 30 * time.Minute
|
||||
}
|
||||
@ -158,7 +153,6 @@ func New(cfg AgentConfig) *Agent {
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
maxTurns: cfg.MaxToolTurns,
|
||||
docStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
@ -577,7 +571,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
}
|
||||
}
|
||||
|
||||
for turn := 0; turn < a.maxTurns; turn++ {
|
||||
for turn := 0; ; turn++ {
|
||||
// === 高优先级打断:每次 LLM 调用前检查拦截通道 ===
|
||||
if text := a.drainInterrupt(); text != "" {
|
||||
msgs = append(msgs, agentAPI.Message{
|
||||
@ -600,20 +594,49 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
}
|
||||
|
||||
// 可取消的 LLM 调用:interceptLoop 通过 cancelLLM 打断进行中的请求
|
||||
reqCtx, reqCancel := context.WithCancel(a.ctx)
|
||||
a.llmMu.Lock()
|
||||
a.cancelLLM = reqCancel
|
||||
a.llmMu.Unlock()
|
||||
// 多 LLM 源顺位降级:当当前 provider 失败时,按注册顺序依次尝试
|
||||
var providers []agentAPI.Provider
|
||||
if a.providerManager != nil {
|
||||
providers = a.providerManager.OrderedProviders()
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
providers = []agentAPI.Provider{a.provider}
|
||||
}
|
||||
var resp *agentAPI.CompletionResponse
|
||||
var llmErr error
|
||||
|
||||
resp, err := a.provider.Chat(reqCtx, req)
|
||||
for pi, fbProvider := range providers {
|
||||
if pi > 0 {
|
||||
log.Printf("[agent] LLM fallback: trying provider %q (fallback #%d/%d)",
|
||||
fbProvider.Name(), pi, len(providers)-1)
|
||||
}
|
||||
|
||||
a.llmMu.Lock()
|
||||
a.cancelLLM = nil
|
||||
a.llmMu.Unlock()
|
||||
reqCancel()
|
||||
fCtx, fCancel := context.WithCancel(a.ctx)
|
||||
a.llmMu.Lock()
|
||||
a.cancelLLM = fCancel
|
||||
a.llmMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
return "", toolsUsed, fmt.Errorf("provider: %w", err)
|
||||
resp, llmErr = fbProvider.Chat(fCtx, req)
|
||||
|
||||
a.llmMu.Lock()
|
||||
a.cancelLLM = nil
|
||||
a.llmMu.Unlock()
|
||||
fCancel()
|
||||
|
||||
if llmErr == nil {
|
||||
if fbProvider != a.provider {
|
||||
a.provider = fbProvider
|
||||
log.Printf("[agent] switched active provider to %q after fallback",
|
||||
fbProvider.Name())
|
||||
}
|
||||
break
|
||||
}
|
||||
log.Printf("[agent] provider %q failed: %v", fbProvider.Name(), llmErr)
|
||||
}
|
||||
|
||||
if llmErr != nil {
|
||||
return "", toolsUsed, fmt.Errorf("all %d providers failed, last error: %w",
|
||||
len(providers), llmErr)
|
||||
}
|
||||
|
||||
// === Stage: post_action — LLM 返回,插件可审查/修改 ===
|
||||
@ -678,10 +701,8 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
"result": result,
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return "", toolsUsed, fmt.Errorf("tool execution exceeded %d turns", a.maxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
func convertToolCalls(tcs []agentAPI.ToolCall) []sdk.ToolCall {
|
||||
@ -767,6 +788,8 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string {
|
||||
if a.stageHost != nil {
|
||||
if result, err := a.stageHost.ExecuteTool(tc.Name, tc.Arguments); err == nil {
|
||||
return fmt.Sprintf("%v", result)
|
||||
} else if !strings.Contains(err.Error(), "not found in any plugin") {
|
||||
return fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1242,6 +1265,30 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
return prompt
|
||||
}
|
||||
|
||||
// cleanParams removes empty required arrays from tool parameters that strict APIs reject.
|
||||
func cleanParams(params map[string]interface{}) map[string]interface{} {
|
||||
if params == nil {
|
||||
return nil
|
||||
}
|
||||
cleaned := make(map[string]interface{}, len(params))
|
||||
for k, v := range params {
|
||||
cleaned[k] = v
|
||||
}
|
||||
if req, ok := cleaned["required"]; ok {
|
||||
switch v := req.(type) {
|
||||
case []interface{}:
|
||||
if len(v) == 0 {
|
||||
delete(cleaned, "required")
|
||||
}
|
||||
case []string:
|
||||
if len(v) == 0 {
|
||||
delete(cleaned, "required")
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func (a *Agent) buildToolDefs() []interface{} {
|
||||
var tools []interface{}
|
||||
|
||||
@ -1252,7 +1299,7 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
"function": map[string]interface{}{
|
||||
"name": td.Name,
|
||||
"description": td.Description,
|
||||
"parameters": td.Parameters,
|
||||
"parameters": cleanParams(td.Parameters),
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -1266,7 +1313,7 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
"function": map[string]interface{}{
|
||||
"name": td.Name,
|
||||
"description": td.Description,
|
||||
"parameters": td.Parameters,
|
||||
"parameters": cleanParams(td.Parameters),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -275,6 +275,7 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
|
||||
set("core.agent.max_tool_turns", "10")
|
||||
set("core.agent.max_context_size", "30")
|
||||
set("core.agent.distill_interval", "30m")
|
||||
set("core.agent.workdir", "")
|
||||
|
||||
set("core.input_processing.image.fallback_provider", "")
|
||||
set("core.input_processing.image.fallback_model", "")
|
||||
@ -352,6 +353,7 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
|
||||
reg(ConfigDef{Key: "core.agent.max_tool_turns", Default: "10", Type: "int", DisplayName: "最大工具轮次", Description: "单次请求允许的最大工具调用轮数", Category: "agent"})
|
||||
reg(ConfigDef{Key: "core.agent.max_context_size", Default: "30", Type: "int", DisplayName: "最大上下文", Description: "上下文窗口中保留的最大消息条数", Category: "agent"})
|
||||
reg(ConfigDef{Key: "core.agent.distill_interval", Default: "30m", Type: "duration", DisplayName: "蒸馏间隔", Description: "记忆蒸馏的执行间隔", Category: "agent"})
|
||||
reg(ConfigDef{Key: "core.agent.workdir", Default: "", Type: "string", DisplayName: "工作目录", Description: "Agent 命令执行的默认工作目录(如 cmd_run 工具的 fallback),留空使用内核所在目录", Category: "agent"})
|
||||
|
||||
reg(ConfigDef{Key: "core.input_processing.image.fallback_provider", Default: "", Type: "string", DisplayName: "图片回退提供商", Description: "当主 LLM 不支持图片处理时使用的提供商(留空则自动降级为文字描述)", Category: "input"})
|
||||
reg(ConfigDef{Key: "core.input_processing.image.fallback_model", Default: "", Type: "string", DisplayName: "图片回退模型", Description: "图片回退提供商使用的模型名", Category: "input"})
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@ -51,9 +53,23 @@ func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, err
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
p, err := plugin.Open(soPath)
|
||||
// 复制到临时路径以绕过 Go plugin.Open 的路径缓存
|
||||
data, err := os.ReadFile(soPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin.Open %s: %w", soPath, err)
|
||||
return nil, fmt.Errorf("read %s: %w", soPath, err)
|
||||
}
|
||||
h := sha256.Sum256(data)
|
||||
cacheKey := fmt.Sprintf("plugin_%s_%s.so", name, hex.EncodeToString(h[:8]))
|
||||
cachePath := filepath.Join(os.TempDir(), cacheKey)
|
||||
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(cachePath, data, 0644); err != nil {
|
||||
return nil, fmt.Errorf("write cache %s: %w", cachePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
p, err := plugin.Open(cachePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin.Open %s: %w", cachePath, err)
|
||||
}
|
||||
|
||||
sym, err := p.Lookup("NewPlugin")
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/agentcli"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cmd"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/files"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw"
|
||||
|
||||
483
internal/plugins/files/plugin.go
Normal file
483
internal/plugins/files/plugin.go
Normal file
@ -0,0 +1,483 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugin.RegisterFactory("files", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return New(name), nil
|
||||
})
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
mu sync.RWMutex
|
||||
filesDir string
|
||||
}
|
||||
|
||||
func New(name string) *Plugin {
|
||||
return &Plugin{name: name}
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "dir",
|
||||
Default: "/",
|
||||
Type: "string",
|
||||
DisplayName: "文件系统根目录",
|
||||
Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)",
|
||||
Category: "files",
|
||||
})
|
||||
|
||||
dir := getSetting[string](s.Settings(), "dir", "/")
|
||||
if strings.HasPrefix(dir, "~/") {
|
||||
home, _ := os.UserHomeDir()
|
||||
dir = filepath.Join(home, dir[2:])
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve files.dir: %w", err)
|
||||
}
|
||||
p.filesDir = abs
|
||||
|
||||
tp := p.name + "_"
|
||||
|
||||
s.RegisterTool(tp+"read", sdk.ToolDef{
|
||||
Name: tp + "read",
|
||||
Description: fmt.Sprintf("读取文件内容。支持 offset/limit 分段读取大文件。沙箱路径: %s", p.filesDir),
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "文件路径(绝对路径或相对于沙箱的路径)"},
|
||||
"offset": map[string]interface{}{"type": "integer", "description": "起始行号(从1开始,可选,默认1)"},
|
||||
"limit": map[string]interface{}{"type": "integer", "description": "最多返回的行数(可选,默认全部)"},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
}, p.handleRead)
|
||||
|
||||
s.RegisterTool(tp+"write", sdk.ToolDef{
|
||||
Name: tp + "write",
|
||||
Description: fmt.Sprintf("写入文件。自动创建父目录。沙箱路径: %s", p.filesDir),
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "要写入的内容"},
|
||||
"mode": map[string]interface{}{"type": "string", "description": "写入模式: overwrite(覆盖,默认)| append(追加到末尾)| insert(插入到指定行)| create(创建新文件,已存在则报错)"},
|
||||
"line": map[string]interface{}{"type": "integer", "description": "插入模式时的目标行号(从1开始),内容将插入到该行之前"},
|
||||
},
|
||||
"required": []string{"path", "content"},
|
||||
},
|
||||
}, p.handleWrite)
|
||||
|
||||
s.RegisterTool(tp+"edit", sdk.ToolDef{
|
||||
Name: tp + "edit",
|
||||
Description: fmt.Sprintf("对文件执行精确字符串替换。每个 old 必须在原文中唯一匹配。沙箱路径: %s", p.filesDir),
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
|
||||
"edits": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "一个或多个替换操作。每个 old 必须在原文中恰好出现一次。不要包含重叠的 edit。",
|
||||
"items": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"old": map[string]interface{}{"type": "string", "description": "要查找的原文(必须在文件中唯一)"},
|
||||
"new": map[string]interface{}{"type": "string", "description": "替换后的文本"},
|
||||
},
|
||||
"required": []string{"old", "new"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "edits"},
|
||||
},
|
||||
}, p.handleEdit)
|
||||
|
||||
s.RegisterTool(tp+"ls", sdk.ToolDef{
|
||||
Name: tp + "ls",
|
||||
Description: fmt.Sprintf("列出目录内容。目录以 / 后缀标记。沙箱路径: %s", p.filesDir),
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "目录路径(可选,默认为沙箱根目录)"},
|
||||
"limit": map[string]interface{}{"type": "integer", "description": "最多返回条目数(可选,默认500)"},
|
||||
},
|
||||
},
|
||||
}, p.handleLs)
|
||||
|
||||
log.Printf("[%s] started, sandbox: %s", p.name, p.filesDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
log.Printf("[%s] stopped", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) resolvePath(userPath string) (string, error) {
|
||||
if userPath == "" {
|
||||
userPath = "."
|
||||
}
|
||||
if !filepath.IsAbs(userPath) {
|
||||
userPath = filepath.Join(p.filesDir, userPath)
|
||||
}
|
||||
abs, err := filepath.Abs(userPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve path: %w", err)
|
||||
}
|
||||
base := filepath.Clean(p.filesDir)
|
||||
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base {
|
||||
return "", fmt.Errorf("path outside sandbox: %s", userPath)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return errorResult("path is required"), nil
|
||||
}
|
||||
|
||||
absPath, err := p.resolvePath(path)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errorResult("file not found: " + path), nil
|
||||
}
|
||||
return errorResult("stat error: " + err.Error()), nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return errorResult("is a directory, use ls instead: " + path), nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return errorResult("read error: " + err.Error()), nil
|
||||
}
|
||||
|
||||
text := string(data)
|
||||
lines := strings.Split(text, "\n")
|
||||
totalLines := len(lines)
|
||||
|
||||
offset := 0
|
||||
if v, ok := args["offset"].(float64); ok && v > 0 {
|
||||
offset = int(v) - 1
|
||||
}
|
||||
if offset >= totalLines {
|
||||
return errorResult(fmt.Sprintf("offset %d exceeds file length (%d lines)", offset+1, totalLines)), nil
|
||||
}
|
||||
|
||||
limit := totalLines - offset
|
||||
if v, ok := args["limit"].(float64); ok && v > 0 {
|
||||
if int(v) < limit {
|
||||
limit = int(v)
|
||||
}
|
||||
}
|
||||
|
||||
end := offset + limit
|
||||
if end > totalLines {
|
||||
end = totalLines
|
||||
}
|
||||
|
||||
selected := lines[offset:end]
|
||||
output := strings.Join(selected, "\n")
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(output)
|
||||
|
||||
if end < totalLines {
|
||||
nextOffset := end + 1
|
||||
sb.WriteString(fmt.Sprintf("\n\n[Showing lines %d-%d of %d. Use offset=%d to continue.]", offset+1, end, totalLines, nextOffset))
|
||||
} else if offset > 0 {
|
||||
sb.WriteString(fmt.Sprintf("\n\n[%d lines total]", totalLines))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": sb.String(),
|
||||
"size": len(data),
|
||||
"lines": totalLines,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleWrite(args map[string]interface{}) (interface{}, error) {
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return errorResult("path is required"), nil
|
||||
}
|
||||
content, _ := args["content"].(string)
|
||||
mode, _ := args["mode"].(string)
|
||||
if mode == "" {
|
||||
mode = "overwrite"
|
||||
}
|
||||
|
||||
line := 0
|
||||
if v, ok := args["line"].(float64); ok && v > 0 {
|
||||
line = int(v)
|
||||
}
|
||||
|
||||
absPath, err := p.resolvePath(path)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "create":
|
||||
if _, err := os.Stat(absPath); err == nil {
|
||||
return errorResult("file already exists: " + path), nil
|
||||
}
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return errorResult("mkdir error: " + err.Error()), nil
|
||||
}
|
||||
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||
return errorResult("write error: " + err.Error()), nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("Created %s (%d bytes)", path, len(content)),
|
||||
}, nil
|
||||
|
||||
case "append":
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return errorResult("mkdir error: " + err.Error()), nil
|
||||
}
|
||||
f, err := os.OpenFile(absPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return errorResult("open error: " + err.Error()), nil
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
return errorResult("append error: " + err.Error()), nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("Appended %d bytes to %s", len(content), path),
|
||||
}, nil
|
||||
|
||||
case "insert":
|
||||
if line < 1 {
|
||||
return errorResult("line must be >= 1 for insert mode"), nil
|
||||
}
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errorResult("file not found: " + path), nil
|
||||
}
|
||||
return errorResult("read error: " + err.Error()), nil
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
if line > len(lines)+1 {
|
||||
return errorResult(fmt.Sprintf("line %d exceeds file length (%d lines)", line, len(lines))), nil
|
||||
}
|
||||
idx := line - 1
|
||||
newLines := make([]string, 0, len(lines)+1)
|
||||
newLines = append(newLines, lines[:idx]...)
|
||||
newLines = append(newLines, content)
|
||||
newLines = append(newLines, lines[idx:]...)
|
||||
result := strings.Join(newLines, "\n")
|
||||
if err := os.WriteFile(absPath, []byte(result), 0644); err != nil {
|
||||
return errorResult("write error: " + err.Error()), nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("Inserted %d bytes at line %d in %s", len(content), line, path),
|
||||
}, nil
|
||||
|
||||
default: // overwrite
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return errorResult("mkdir error: " + err.Error()), nil
|
||||
}
|
||||
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||
return errorResult("write error: " + err.Error()), nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("Wrote %d bytes to %s", len(content), path),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return errorResult("path is required"), nil
|
||||
}
|
||||
|
||||
absPath, err := p.resolvePath(path)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
|
||||
rawEdits, ok := args["edits"].([]interface{})
|
||||
if !ok || len(rawEdits) == 0 {
|
||||
return errorResult("edits must be a non-empty array"), nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errorResult("file not found: " + path), nil
|
||||
}
|
||||
return errorResult("read error: " + err.Error()), nil
|
||||
}
|
||||
|
||||
original := string(data)
|
||||
content := original
|
||||
applied := 0
|
||||
var errors []string
|
||||
|
||||
for i, raw := range rawEdits {
|
||||
edit, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
errors = append(errors, fmt.Sprintf("edit[%d]: invalid format", i))
|
||||
continue
|
||||
}
|
||||
oldText, _ := edit["old"].(string)
|
||||
newText, _ := edit["new"].(string)
|
||||
if oldText == "" {
|
||||
errors = append(errors, fmt.Sprintf("edit[%d]: old is required", i))
|
||||
continue
|
||||
}
|
||||
|
||||
count := strings.Count(content, oldText)
|
||||
if count == 0 {
|
||||
errors = append(errors, fmt.Sprintf("edit[%d]: could not find %q in %s", i, oldText, path))
|
||||
continue
|
||||
}
|
||||
if count > 1 {
|
||||
errors = append(errors, fmt.Sprintf("edit[%d]: found %d occurrences of %q, must be unique", i, count, oldText))
|
||||
continue
|
||||
}
|
||||
|
||||
content = strings.Replace(content, oldText, newText, 1)
|
||||
applied++
|
||||
}
|
||||
|
||||
if applied == 0 {
|
||||
msg := "no edits applied"
|
||||
if len(errors) > 0 {
|
||||
msg += ": " + strings.Join(errors, "; ")
|
||||
}
|
||||
return errorResult(msg), nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||
return errorResult("write error: " + err.Error()), nil
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Successfully applied %d/%d edits to %s", applied, len(rawEdits), path)
|
||||
if len(errors) > 0 {
|
||||
msg += "\nWarnings:\n" + strings.Join(errors, "\n")
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": msg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleLs(args map[string]interface{}) (interface{}, error) {
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
|
||||
absPath, err := p.resolvePath(path)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errorResult("path not found: " + path), nil
|
||||
}
|
||||
return errorResult("stat error: " + err.Error()), nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return errorResult("not a directory: " + path), nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(absPath)
|
||||
if err != nil {
|
||||
return errorResult("readdir error: " + err.Error()), nil
|
||||
}
|
||||
|
||||
limit := 500
|
||||
if v, ok := args["limit"].(float64); ok && v > 0 {
|
||||
limit = int(v)
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
|
||||
})
|
||||
|
||||
var lines []string
|
||||
entryLimitReached := false
|
||||
for i, entry := range entries {
|
||||
if i >= limit {
|
||||
entryLimitReached = true
|
||||
break
|
||||
}
|
||||
name := entry.Name()
|
||||
if entry.IsDir() {
|
||||
name += "/"
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err == nil {
|
||||
name = fmt.Sprintf("%-40s %8d", name, info.Size())
|
||||
}
|
||||
lines = append(lines, name)
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": "(empty directory)",
|
||||
}, nil
|
||||
}
|
||||
|
||||
output := strings.Join(lines, "\n")
|
||||
if entryLimitReached {
|
||||
output += fmt.Sprintf("\n\n[%d entries limit reached. Use limit=N for more.]", limit)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": output,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func errorResult(msg string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"isError": true,
|
||||
"content": msg,
|
||||
}
|
||||
}
|
||||
|
||||
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
|
||||
v, err := s.Get(key)
|
||||
if err != nil || v == nil {
|
||||
return def
|
||||
}
|
||||
val, ok := v.(T)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
return val
|
||||
}
|
||||
Reference in New Issue
Block a user