llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
200 lines
5.8 KiB
Go
200 lines
5.8 KiB
Go
package tools
|
||
|
||
import "strings"
|
||
|
||
// editMatch 是 old_string 在文件内容里的一个字节区间 [start, end)。
|
||
type editMatch struct {
|
||
start int
|
||
end int
|
||
}
|
||
|
||
// findEditMatches 按三级降级查找 old_string 的出现位置:
|
||
//
|
||
// 1. 精确匹配:字节完全一致,命中后不再降级;
|
||
// 2. 字形归一:把弯引号、破折号、不间断空格等"看起来一样"的字符归一后匹配
|
||
// (模型从网页/文档复制的文本经常带这些字符,精确匹配会失败);
|
||
// 3. 逐行降级:忽略每行行尾空白后再匹配,用来兜住"多了几个空格/少了几个空格"。
|
||
//
|
||
// 返回的区间始终指向**原文**,调用方据此替换,不会把归一化后的字符写回文件。
|
||
func findEditMatches(content, old string) []editMatch {
|
||
if old == "" {
|
||
return nil
|
||
}
|
||
if matches := findExactMatches(content, old); len(matches) > 0 {
|
||
return matches
|
||
}
|
||
if matches := findNormalizedMatches(content, old); len(matches) > 0 {
|
||
return matches
|
||
}
|
||
return findLineMatches(content, old)
|
||
}
|
||
|
||
// findExactMatches 返回不重叠的精确出现位置(已是字节区间)。
|
||
func findExactMatches(content, old string) []editMatch {
|
||
var out []editMatch
|
||
offset := 0
|
||
for {
|
||
i := strings.Index(content[offset:], old)
|
||
if i < 0 {
|
||
return out
|
||
}
|
||
start := offset + i
|
||
out = append(out, editMatch{start: start, end: start + len(old)})
|
||
offset = start + len(old)
|
||
}
|
||
}
|
||
|
||
// findNormalizedMatches 在字形归一后的 rune 序列上匹配,再把 rune 下标映射回
|
||
// 原始字节区间。归一化是逐 rune 的一对一替换,所以下标可以直接对应。
|
||
func findNormalizedMatches(content, old string) []editMatch {
|
||
contentRunes := []rune(content)
|
||
oldRunes := []rune(old)
|
||
if len(oldRunes) == 0 || len(oldRunes) > len(contentRunes) {
|
||
return nil
|
||
}
|
||
prefix := runeByteOffsets(contentRunes)
|
||
var out []editMatch
|
||
for i := 0; i+len(oldRunes) <= len(contentRunes); i++ {
|
||
match := true
|
||
for j := range oldRunes {
|
||
if normalizeEditRune(contentRunes[i+j]) != normalizeEditRune(oldRunes[j]) {
|
||
match = false
|
||
break
|
||
}
|
||
}
|
||
if !match {
|
||
continue
|
||
}
|
||
out = append(out, editMatch{start: prefix[i], end: prefix[i+len(oldRunes)]})
|
||
i += len(oldRunes) - 1
|
||
}
|
||
return out
|
||
}
|
||
|
||
// findLineMatches 逐行比较(忽略行尾空白与字形差异),返回整行窗口的字节区间。
|
||
// old 带结尾换行时连同最后一行的换行一起替换;不带时保留原文换行。
|
||
func findLineMatches(content, old string) []editMatch {
|
||
trailingNewline := strings.HasSuffix(old, "\n")
|
||
expected := strings.Split(old, "\n")
|
||
if trailingNewline {
|
||
expected = expected[:len(expected)-1]
|
||
}
|
||
if len(expected) == 0 {
|
||
return nil
|
||
}
|
||
lines := splitLines(content)
|
||
var out []editMatch
|
||
for i := 0; i+len(expected) <= len(lines); i++ {
|
||
matched := true
|
||
for j := range expected {
|
||
if normalizeEditLine(lines[i+j].text) != normalizeEditLine(expected[j]) {
|
||
matched = false
|
||
break
|
||
}
|
||
}
|
||
if !matched {
|
||
continue
|
||
}
|
||
last := lines[i+len(expected)-1]
|
||
end := last.end
|
||
if !trailingNewline {
|
||
end = last.contentEnd
|
||
}
|
||
if len(out) > 0 && out[len(out)-1].end > lines[i].start {
|
||
continue // 与上一个命中重叠:同一个位置只算一次
|
||
}
|
||
out = append(out, editMatch{start: lines[i].start, end: end})
|
||
i += len(expected) - 1
|
||
}
|
||
return out
|
||
}
|
||
|
||
type lineSpan struct {
|
||
start int
|
||
end int // 含换行
|
||
contentEnd int // 不含 \r\n / \n
|
||
text string
|
||
}
|
||
|
||
// splitLines 切出每一行及其字节区间。空内容返回空切片;末尾换行不产生空行。
|
||
func splitLines(s string) []lineSpan {
|
||
var out []lineSpan
|
||
for start := 0; start < len(s); {
|
||
i := strings.IndexByte(s[start:], '\n')
|
||
if i < 0 {
|
||
contentEnd := len(s)
|
||
if contentEnd > start && s[contentEnd-1] == '\r' {
|
||
contentEnd--
|
||
}
|
||
out = append(out, lineSpan{start: start, end: len(s), contentEnd: contentEnd, text: s[start:contentEnd]})
|
||
break
|
||
}
|
||
end := start + i + 1
|
||
contentEnd := end - 1
|
||
if contentEnd > start && s[contentEnd-1] == '\r' {
|
||
contentEnd--
|
||
}
|
||
out = append(out, lineSpan{start: start, end: end, contentEnd: contentEnd, text: s[start:contentEnd]})
|
||
start = end
|
||
}
|
||
return out
|
||
}
|
||
|
||
// runeByteOffsets 返回每个 rune 起始处的字节偏移,外加末尾哨兵。
|
||
func runeByteOffsets(runes []rune) []int {
|
||
out := make([]int, len(runes)+1)
|
||
offset := 0
|
||
for i, r := range runes {
|
||
out[i] = offset
|
||
offset += len(string(r))
|
||
}
|
||
out[len(runes)] = offset
|
||
return out
|
||
}
|
||
|
||
// normalizeEditRune 把视觉上等价、字节上不同的字符归一。
|
||
func normalizeEditRune(r rune) rune {
|
||
switch r {
|
||
case '‘', '’', '‚', '‛':
|
||
return '\''
|
||
case '“', '”', '„', '‟':
|
||
return '"'
|
||
case '‐', '‑', '‒', '–', '—', '―', '−', '-':
|
||
return '-'
|
||
case '\u00A0', '\u2002', '\u2003', '\u2004', '\u2005', '\u2006', '\u2007', '\u2008',
|
||
'\u2009', '\u200A', '\u202F', '\u205F', '\u3000':
|
||
return ' '
|
||
}
|
||
return r
|
||
}
|
||
|
||
func normalizeEditLine(line string) string {
|
||
line = strings.TrimRight(line, " \t\r")
|
||
return string([]rune(normalizeEditTextRunes(line)))
|
||
}
|
||
|
||
func normalizeEditTextRunes(s string) []rune {
|
||
out := []rune(s)
|
||
for i := range out {
|
||
out[i] = normalizeEditRune(out[i])
|
||
}
|
||
return out
|
||
}
|
||
|
||
// applyEditMatches 从后往前替换,前面的偏移因此不受影响。
|
||
// 替换文本沿用命中区域的换行风格:命中区是 CRLF 而 new 用 LF 时,自动转成 CRLF,
|
||
// 避免一次编辑把整个文件的行尾改掉。
|
||
func applyEditMatches(content string, matches []editMatch, newText string) string {
|
||
updated := content
|
||
for i := len(matches) - 1; i >= 0; i-- {
|
||
m := matches[i]
|
||
region := content[m.start:m.end]
|
||
replacement := newText
|
||
if strings.Contains(region, "\r\n") && !strings.Contains(replacement, "\r\n") {
|
||
replacement = strings.ReplaceAll(replacement, "\n", "\r\n")
|
||
}
|
||
updated = updated[:m.start] + replacement + updated[m.end:]
|
||
}
|
||
return updated
|
||
}
|