// Package spill 把超长的工具结果落盘,并返回一个模型可用 read 工具取回的路径。 // 动机:视图层为省上下文会截断超长 tool result,模型因此拿不回原文;落盘后 // 截断提示里带上路径,模型需要时能读回全文。 package spill import ( "crypto/sha256" "encoding/hex" "fmt" "os" "path/filepath" "strings" "time" ) // maxAge 是落盘文件的保留时长,超过即在 Prune 时删除(防无限增长)。 const maxAge = 7 * 24 * time.Hour // Dir 返回落盘目录(~/.autogo/results)。与 sessions/images/logs 同惯例; // 取不到 HOME 时退回相对目录。 func Dir() string { home, err := os.UserHomeDir() if err != nil || home == "" { return filepath.Join(".autogo", "results") } return filepath.Join(home, ".autogo", "results") } // Save 把 text 写入 dir 并返回文件路径。 // 文件名优先用消毒后的 callID(同一工具调用幂等、可读),为空时退回内容哈希。 // 已存在同路径文件则直接复用,不重复写。 func Save(dir, callID, text string) (string, error) { if dir == "" { return "", fmt.Errorf("spill: empty dir") } if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } name := sanitize(callID) if name == "" { sum := sha256.Sum256([]byte(text)) name = "sha-" + hex.EncodeToString(sum[:])[:16] } path := filepath.Join(dir, name+".txt") if _, err := os.Stat(path); err == nil { return path, nil // 幂等:内容已落盘 } // tmp + rename 原子写,避免读到半截文件。 tmp, err := os.CreateTemp(dir, name+".*.tmp") if err != nil { return "", err } tmpName := tmp.Name() if _, err := tmp.WriteString(text); err != nil { tmp.Close() os.Remove(tmpName) return "", err } if err := tmp.Close(); err != nil { os.Remove(tmpName) return "", err } if err := os.Rename(tmpName, path); err != nil { os.Remove(tmpName) return "", err } return path, nil } // Prune 删除 dir 下超过 maxAge 的文件;目录不存在时静默返回。 func Prune(dir string) { entries, err := os.ReadDir(dir) if err != nil { return } cutoff := time.Now().Add(-maxAge) for _, e := range entries { if e.IsDir() { continue } info, err := e.Info() if err != nil || info.ModTime().After(cutoff) { continue } os.Remove(filepath.Join(dir, e.Name())) } } // sanitize 把 callID 收敛为安全的文件名片段:只留字母数字与 . _ -, // 其余替换为 _,并丢弃 . / .. 等特殊值(防路径穿越)。 func sanitize(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } var b strings.Builder for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': b.WriteRune(r) case r == '.': b.WriteRune('_') // 不留 ".",杜绝 ".." 之类 default: b.WriteRune('_') } } out := b.String() if out == "" { return "" } // 限长,避免超长 callID 造成文件名过长。 if len(out) > 64 { out = out[:64] } return out }