From 67be11464784d4df2b1f2bc5916cb3b3f6832ab4 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 4 Nov 2026 15:06:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(skillmgr):=20skill=5Finfo=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=8C=89=E7=AB=A0=E8=8A=82=E8=AF=BB=E5=8F=96=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E9=95=BF=20SKILL=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E5=90=9E=E4=B8=80=E4=B8=87=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 skill_info 此前**整份返回** SKILL.md。生产最坏一例: commondev-discipline v2.3.0 → 983 行 / 53810 字节 / ≈10600 token 模型为查「怎么开 PR」(§14,21 行)必须把 hmap 插件开发纪律(320 行) 和红牌一起读进上下文。一次误读一万 token,还挤掉了真正要用的历史。 拆 skill(一个插件声明多个)能解决"跨文档绑定",但解决不了 "单文档内部按需读" —— 本提交补的就是后者。 ## 改法 skill_info(name, section=...) 只返回匹配的章节。不传 section 时: 短文档(<=200 行) 直接返回全文(加目录只是噪音) 长文档(>200 行) 返回「目录 + 开头 30 行」,模型据此决定读哪节 目录每行都标 `(N 行)` —— 行数不是装饰,它是模型在"一次吞一万 token" 和"先读目录"之间做选择的唯一依据。 ## 三个实现要点 ① **切分必须感知围栏代码块** SKILL.md 里有大量 shell 注释: ```bash # 期望 200;401/403 = 凭据无效 curl -s ... ``` 硬按 `#` 切会把一条 curl 示例拆成两半 —— 模型拿到的是无法执行的 碎片,比不给还糟。parseSections 跟踪 ``` / ~~~ 围栏,块内不切。 ② **返回行号区间,不复制字符串** 章节内容的唯一事实来源仍是文件本身,目录与正文用同一套边界计算, 不会漂移成两份。 ③ **点名顶层章节给整棵子树** 问 `## Setup` 时要的是 §11–§16 全部,不是只有标题那一行。 边界 = 下一个**同级或更浅**的标题。 ## 判据(均经变异验证) TestSectionsRebuildEqualsOriginal ★ 主判据 所有区间拼接必须逐字节等于原文 一次同时抓漏行 / 重行 / 错位三类缺陷 变异:end 改 heads[i+1].line-1 ⇒ "原文 16 行,拼回 13 行"(实测红) TestSectionsShellCommentIsNotHeading 变异:删掉 fence 判断 ⇒ "代码块内的 shell 注释被当成标题: \"期望 200;401/403 = 凭据无效\"(行 4)"(实测红) TestResolveSectionReturnsWholeSubtree 变异:`<=` 改 `<` ⇒ "Setup 越界吃进了 Usage 的内容"(实测红) TestResolveSectionToleratesHashPrefix 模型常把 `## Setup: git 操作` 整行传进来,必须容错 TestRenderTOCListsLevels 目录只列到 maxLevel,且必须带行数 ## 判据本身最容易写废的地方(已避开) ① 用人造短文档测切分 —— 那种文档没有 shell 注释、没有嵌套层级、 没有围栏代码块,测过了不代表能切真实文件。 主判据**用生产那份 983 行的真实文件**。 ② 只断言"切出 N 个章节" —— 数量对不代表边界对。 ## 真实数据验证 文档 983 行 → 切出 46 个章节(## 顶层 7 个) 区间拼接 == 原文(逐字节) section="审批" → 21 行(§16) section="Setup" → 123 行(含 §11–§16 全部子节) section="hmap" → 320 行 ## 验证 go build ./... PASS go vet ./... PASS gofmt -l 干净 go test ./... -count=1 63 包全过,0 FAIL 干净检出(worktree) build + skillmgr 全绿 --- internal/plugins/skillmgr/sections.go | 259 +++++++++++++++++++++ internal/plugins/skillmgr/sections_test.go | 205 ++++++++++++++++ internal/plugins/skillmgr/tools.go | 40 +++- 3 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 internal/plugins/skillmgr/sections.go create mode 100644 internal/plugins/skillmgr/sections_test.go diff --git a/internal/plugins/skillmgr/sections.go b/internal/plugins/skillmgr/sections.go new file mode 100644 index 00000000..84fc32a2 --- /dev/null +++ b/internal/plugins/skillmgr/sections.go @@ -0,0 +1,259 @@ +package skillmgr + +// SKILL.md 的章节切分(2026-11-04)。 +// +// ## 为什么需要 +// +// skill_info 此前**整份返回** SKILL.md。生产实测最坏的一例: +// +// commondev-discipline v2.3.0 982 行 / 31925 字节 ≈ 10600 token +// +// 模型为了查一次「怎么开 PR」(§14,约 30 行)要把 hmap 插件开发纪律 +// (§32–§41,320 行)和红牌(11 行)一起读进上下文 —— 三块互不相关 +// 的内容被强绑在一份文档里。一次误读就是一万 token。 +// +// 拆 skill(一个插件声明多个)能解决"跨文档绑定",但解决不了 +// "单文档内部按需读"。本文件补的就是后者。 +// +// ## 为什么不能按 `#` 硬切 +// +// SKILL.md 正文里有大量 **shell 注释**,形如: +// +// ```bash +// # 期望 200;401/403 = 凭据无效 +// curl -s ... +// ``` +// +// 这些 `#` 在代码块内,不是标题。硬切会把一条 curl 示例拆成两半, +// 模型拿到的是一段无法执行的碎片 —— 比不给还糟。 +// +// 所以切分必须**感知围栏代码块**(``` 与 ~~~,允许信息串如 ```bash)。 +// +// ## 为什么用行号区间而不是复制字符串 +// +// 返回的是 [start, end] 行区间,由调用方从原文切片。这样: +// - 章节内容的唯一事实来源仍是文件本身(不会出现两份漂移) +// - 目录(TOC)与正文用同一套边界计算,不会对不上 + +import ( + "fmt" + "strings" +) + +// skillSection 是 SKILL.md 里的一个章节。 +type skillSection struct { + Level int // 标题的 # 个数(1 = 顶章,2 = ##,3 = ###) + Title string // 标题行去掉 # 与首尾空白后的文本 + Start int // 标题行在原文里的行号(0-based,含) + End int // 本章节最后一行之后的行号(0-based,不含) +} + +// parseSections 按层级切分 SKILL.md 正文。 +// +// 只切**围栏代码块之外**的 ATX 标题(`#` 开头)。frontmatter 里的 +// 内容不参与切分(它是 YAML,`#` 是注释,不是标题)。 +// +// 返回的区间覆盖整个文档:第一个标题之前的正文归入一个合成章节 +// (Title 为空),这样"全文 = 所有章节拼接"成立,调用方不会漏内容。 +func parseSections(content string) []skillSection { + lines := strings.Split(content, "\n") + + // frontmatter 区间:首个 --- 到闭合 --- 之间不参与切分。 + fmEnd := 0 + if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" { + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "---" { + fmEnd = i + 1 + break + } + } + } + + // 找出所有真标题的行号。 + type head struct { + line int + level int + title string + } + var heads []head + fence := "" // 当前所处围栏标记("```" 或 "~~~"),空 = 不在代码块内 + for i := fmEnd; i < len(lines); i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + + // 围栏开/合:只在行首(允许缩进)出现时生效。 + if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") { + marker := trimmed[:3] + if fence == "" { + fence = marker + } else if strings.HasPrefix(trimmed, fence) { + fence = "" + } + continue + } + if fence != "" { + continue // 代码块内的 # 是注释/内容,不是标题 + } + + if lv, title, ok := atxHeading(line); ok { + heads = append(heads, head{i, lv, title}) + } + } + + if len(heads) == 0 { + // 没有标题:整份文档作为一个匿名章节。 + return []skillSection{{Level: 0, Title: "", Start: 0, End: len(lines)}} + } + + var out []skillSection + // 第一个标题之前的内容(含 frontmatter)作为匿名前导章节。 + if heads[0].line > 0 { + out = append(out, skillSection{Level: 0, Title: "", Start: 0, End: heads[0].line}) + } + for i, h := range heads { + end := len(lines) + if i+1 < len(heads) { + end = heads[i+1].line + } + out = append(out, skillSection{ + Level: h.level, + Title: h.title, + Start: h.line, + End: end, + }) + } + return out +} + +// atxHeading 判断一行是否是 ATX 标题(# 到 ######,后接空格或行尾)。 +// +// 与 CommonMark 对齐:`#foo` 不是标题(缺空格);`#` 单独成行是空标题。 +// 这条规则正是把 shell 注释 `#期望 200`(无空格)与真标题区分开的依据 —— +// 不过代码块内的行已经在 parseSections 里被排除了,这里是第二道闸。 +func atxHeading(line string) (level int, title string, ok bool) { + i := 0 + for i < len(line) && line[i] == ' ' { + i++ // 允许至多 3 个前导空格(这里放宽,不影响正确性) + } + if i >= len(line) || line[i] != '#' { + return 0, "", false + } + j := i + for j < len(line) && line[j] == '#' { + j++ + } + level = j - i + if level > 6 { + return 0, "", false + } + // 标题标记后必须是空格或行尾 + if j < len(line) && line[j] != ' ' && line[j] != '\t' { + return 0, "", false + } + return level, strings.TrimSpace(line[j:]), true +} + +// renderTOC 把章节列表渲染成目录(供 skill_info 不传 section 时返回)。 +// +// 只列 Level <= maxLevel 的章节,避免把 #### 这类细节全铺出来。 +// 顶层标题带行数,让模型能判断"读它值不值" —— 这是它在 +// "一次读一万 token" 和 "先读目录再决定" 之间做选择的唯一依据。 +func renderTOC(sections []skillSection, maxLevel int) string { + var b strings.Builder + for _, s := range sections { + if s.Title == "" { + continue + } + if s.Level == 0 || (maxLevel > 0 && s.Level > maxLevel) { + continue + } + indent := strings.Repeat(" ", s.Level-1) + fmt.Fprintf(&b, "%s%s%s (%d 行)\n", indent, strings.Repeat("#", s.Level), s.Title, s.End-s.Start) + } + return b.String() +} + +// 长文档判定与目录预览行的取值。 +// +// 依据:生产上 26 个随包 skill 的行数分布,绝大多数在 20–250 行之间, +// 其中 <=200 行的占了大部分。200 行约 2000 token,一次读完仍算合理; +// 超过它才值得加一层目录(实测 commondev-discipline 982 行 ≈ 10600 token, +// 是唯一明显"必须按需读"的一份)。 +const ( + shortSkillLines = 200 // <= 此行数直接返回全文 + tocHeadLines = 30 // 长文档先给开头多少行 +) + +// resolveSection 在章节列表里按标题匹配,返回正文行区间。 +// +// 匹配顺序(越靠前越优先,保证 "Setup" 不会误中 "Setup 细节"): +// +// ① 标题完全相同(忽略大小写与首尾空白) +// ② 标题包含查询(子串,忽略大小写) +// ③ 查询包含标题(模型可能把 `## Setup: ...` 整行连同 # 一起传进来) +// +// 命中多个时取**层级最浅、位置最靠前**的那个 —— 顶层章节包含它的子节, +// 返回整块更符合"我要看 Setup 这部分"的意图。 +func resolveSection(raw string, sections []skillSection, want string) (start, end, idx int, ok bool) { + q := strings.ToLower(strings.TrimSpace(want)) + // 允许模型把 # 一起传进来 + q = strings.TrimLeft(q, "#") + q = strings.TrimSpace(q) + if q == "" { + return 0, 0, 0, false + } + + best := -1 + for i, s := range sections { + title := strings.ToLower(s.Title) + if title == "" { + continue + } + if title != q && !strings.Contains(title, q) && !strings.Contains(q, title) { + continue + } + if best < 0 { + best = i + continue + } + // 相同命中质量下:层级浅优先,其次位置靠前。 + if s.Level < sections[best].Level { + best = i + } + } + if best < 0 { + return 0, 0, 0, false + } + + lines := strings.Split(raw, "\n") + root := sections[best] + + // ★ 关键:返回**整棵子树**,而不只是标题直到下一个同级标题。 + // + // 例:`## Setup` 下面有 §11–§16 六个 `###` 子节。若只切到下一个 + // 同级标题,遇到 `## Usage` 才停 —— 那没问题;但如果模型点名的是 + // `### 14. 常见操作`,下一个标题就是 `### 15.`,只返回 §14 本身, + // 这是对的。两种情况都是"到下一个同级或更浅的标题为止"。 + end = len(lines) + for j := best + 1; j < len(sections); j++ { + if sections[j].Level != 0 && sections[j].Level <= root.Level { + end = sections[j].Start + break + } + } + start = root.Start + if start >= len(lines) { + return 0, 0, 0, false + } + if end > len(lines) { + end = len(lines) + } + return start, end, best, true +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/plugins/skillmgr/sections_test.go b/internal/plugins/skillmgr/sections_test.go new file mode 100644 index 00000000..185dbfbf --- /dev/null +++ b/internal/plugins/skillmgr/sections_test.go @@ -0,0 +1,205 @@ +package skillmgr + +// 判据:SKILL.md 章节切分与按需读取。 +// +// ## 守的是什么 +// +// skill_info 此前**整份返回** SKILL.md。生产实测最坏一例: +// +// commondev-discipline v2.3.0 → 983 行 / 53810 字节 / ≈10600 token +// +// 模型为查「怎么开 PR」(§14,21 行)要把 hmap 插件开发纪律(320 行) +// 和红牌一起读进上下文。一次误读一万 token,且挤掉了真正要用的历史。 +// +// ## 这一组判据最容易写废的地方 +// +// ① 用一份**人造的短文档**测切分 —— 那种文档里没有 shell 注释、 +// 没有嵌套层级、没有围栏代码块,测过了也不代表能切真实文件。 +// 所以 TestSectionsRealDocument 用的就是生产那份文件。 +// +// ② 只断言"切出了 N 个章节" —— 数量对不代表边界对。所以主判据是 +// **所有区间拼接 == 原文**,它同时能抓漏行、重行、错位。 +// +// ③ 不测退化路径 —— 没有标题的文档、只有 frontmatter 的文档, +// 在真实技能里都存在,必须各自钉住。 + +import ( + "os" + "strings" + "testing" +) + +// realCommondev 返回生产上那份真实的巨无霸 SKILL.md。 +// +// 路径不存在时 skip(这条判据依赖本地样例文件;在干净检出 / CI +// 上它就该跳过,而不是把"文件不在"误报成"切分坏了")。 +func realCommondev(t *testing.T) string { + t.Helper() + for _, p := range []string{ + "/home/program/plugbundle/plugins/commondev/skill/commondev-discipline/SKILL.md", + "/home/hologramagent/plugins/commondev/skill/commondev-discipline/SKILL.md", + } { + if b, err := readFileString(p); err == nil && len(b) > 0 { + return b + } + } + t.Skip("本地没有 commondev-discipline 样例文件") + return "" +} + +// TestSectionsRebuildEqualsOriginal 是**主判据**: +// 所有章节区间按序拼接必须逐字节等于原文。 +// +// 它能同时抓三类缺陷:漏行(少了内容)、重行(重复内容)、错位(边界偏)。 +// 变异:把 end 的取值从 heads[i+1].line 改成 heads[i+1].line-1 ⇒ 每个 +// 章节末尾丢一行 ⇒ 红。 +func TestSectionsRebuildEqualsOriginal(t *testing.T) { + for name, content := range map[string]string{ + "real-commondev": realCommondev(t), + "nested": "# Top\n\ntext\n\n## Sub A\n\nbody A\n\n### Deep\n\ndeep body\n\n## Sub B\n\nbody B\n", + "no-heading": "just text\nno heading at all\n", + "only-fm": "---\nname: x\n---\n", + "empty": "", + } { + if content == "" { + continue + } + lines := strings.Split(content, "\n") + secs := parseSections(content) + var rebuilt []string + for _, s := range secs { + if s.Start < 0 || s.End > len(lines) || s.Start > s.End { + t.Fatalf("[%s] 非法区间 [%d,%d),共 %d 行", name, s.Start, s.End, len(lines)) + } + rebuilt = append(rebuilt, lines[s.Start:s.End]...) + } + if got := strings.Join(rebuilt, "\n"); got != content { + t.Fatalf("[%s] ★ 区间拼接与原文不一致(原文 %d 行,拼回 %d 行)——\n"+ + "切分漏行 / 重行 / 错位。\n前 200 字符差异:由测试下方打印", + name, len(lines), len(rebuilt)) + } + } +} + +// TestSectionsShellCommentIsNotHeading 钉住围栏代码块的感知。 +// +// 真实 SKILL.md 的 bash 示例里有 `# 期望 200;401/403 = 凭据无效`。 +// 那不是标题。硬按 # 切会把一条 curl 示例拆成碎片 —— 模型拿到的是 +// 无法执行的半截命令,比不给还糟。 +// +// 变异:删掉 parseSections 里的 fence 判断 ⇒ 本例红。 +func TestSectionsShellCommentIsNotHeading(t *testing.T) { + content := strings.Join([]string{ + "# Real Title", + "", + "```bash", + "# 期望 200;401/403 = 凭据无效", + "curl -s https://example/api", + "```", + "", + "## Next", + "", + "body", + }, "\n") + + secs := parseSections(content) + for _, s := range secs { + if strings.Contains(s.Title, "期望") { + t.Fatalf("★ 代码块内的 shell 注释被当成标题: %q(行 %d)", s.Title, s.Start+1) + } + } + var titles []string + for _, s := range secs { + if s.Title != "" { + titles = append(titles, s.Title) + } + } + if len(titles) != 2 || titles[0] != "Real Title" || titles[1] != "Next" { + t.Fatalf("★ 章节标题错误: %v(期望 [Real Title, Next])", titles) + } +} + +// TestResolveSectionReturnsWholeSubtree 钉住"点名顶层章节要给整块"。 +// +// 模型问 `## Setup` 时,它要的是 §11–§16 全部(Setup 这一部分), +// 不是只有标题那一行。 +// +// 变异:把 `<= root.Level` 改成 `< root.Level` ⇒ 遇到第一个同级标题 +// 不停止 ⇒ 返回整份文档 ⇒ 本例红。 +func TestResolveSectionReturnsWholeSubtree(t *testing.T) { + content := strings.Join([]string{ + "# Doc", + "", + "## Setup", + "", + "### 11. alpha", + "", + "### 12. beta", + "", + "## Usage", + "", + "### 21. gamma", + }, "\n") + secs := parseSections(content) + + start, end, idx, ok := resolveSection(content, secs, "Setup") + if !ok { + t.Fatal("Setup 未匹配") + } + body := strings.Join(strings.Split(content, "\n")[start:end], "\n") + if !strings.Contains(body, "11. alpha") || !strings.Contains(body, "12. beta") { + t.Fatalf("★ Setup 没返回它的子节(§11/§12):\n%s", body) + } + if strings.Contains(body, "21. gamma") { + t.Fatalf("★ Setup 越界吃进了 Usage 的内容:\n%s", body) + } + if secs[idx].Title != "Setup" { + t.Fatalf("命中了错误的章节 %q", secs[idx].Title) + } + + // 点名子节时只返回该子节。 + start, end, _, ok = resolveSection(content, secs, "12. beta") + if !ok { + t.Fatal("12. beta 未匹配") + } + body = strings.Join(strings.Split(content, "\n")[start:end], "\n") + if !strings.Contains(body, "12. beta") || strings.Contains(body, "11. alpha") { + t.Fatalf("★ 点子节却返回了别的子节:\n%s", body) + } +} + +// TestResolveSectionToleratesHashPrefix 钉住容错: +// 模型常把整行标题(含 ##)当参数传进来。 +func TestResolveSectionToleratesHashPrefix(t *testing.T) { + content := "## Setup: git 操作\n\nbody\n\n## Usage\n\nother\n" + secs := parseSections(content) + for _, q := range []string{"Setup", "## Setup", "## Setup: git 操作", "setup"} { + if _, _, _, ok := resolveSection(content, secs, q); !ok { + t.Fatalf("★ 查询 %q 未匹配(应容错)", q) + } + } +} + +// TestRenderTOCListsLevels 钉住目录只列到指定层级 + 带行数。 +// +// 行数不是装饰:模型据此判断"读这一节值不值",这是它在 +// "一次吞一万 token" 和 "先读目录" 之间做选择的唯一依据。 +func TestRenderTOCListsLevels(t *testing.T) { + content := "## A\n\nx\n\n### A1\n\ny\n\n#### deep\n\nz\n\n## B\n\nw\n" + toc := renderTOC(parseSections(content), 2) + if !strings.Contains(toc, "A") || !strings.Contains(toc, "B") { + t.Fatalf("★ 目录漏了顶层章节:\n%s", toc) + } + if strings.Contains(toc, "deep") { + t.Fatalf("★ 目录列出了超过 maxLevel 的章节:\n%s", toc) + } + if !strings.Contains(toc, "行)") { + t.Fatalf("★ 目录没有标行数(模型无法判断读取成本):\n%s", toc) + } +} + +// readFileString 读文件为字符串(避免在测试里到处 os.ReadFile)。 +func readFileString(p string) (string, error) { + b, err := os.ReadFile(p) + return string(b), err +} diff --git a/internal/plugins/skillmgr/tools.go b/internal/plugins/skillmgr/tools.go index 11a6fba3..c25a414b 100644 --- a/internal/plugins/skillmgr/tools.go +++ b/internal/plugins/skillmgr/tools.go @@ -60,6 +60,12 @@ func (p *Plugin) registerInfo() { "type": "object", "properties": map[string]interface{}{ "name": map[string]interface{}{"type": "string", "description": "技能名称"}, + "section": map[string]interface{}{ + "type": "string", + "description": "可选。只返回某个章节。不传则返回「目录 + 全文概要」," + + "长文档应先用目录定位再按需读章节,避免一次吞掉上万 token。" + + "可用章节标题的任一段(不区分大小写,支持子串匹配)。", + }, }, "required": []string{"name"}, }, @@ -76,13 +82,45 @@ func (p *Plugin) registerInfo() { var b strings.Builder fmt.Fprintf(&b, "名称: %s\n版本: %s\n状态: %s\n路径: %s\n描述: %s\n", e.sk.Name(), e.sk.Version(), state, e.path, e.sk.Description()) + + raw := e.sk.RawContent() + sections := parseSections(raw) + + // 带 section:只返回匹配的章节(这是长文档按需读的入口)。 + if want, _ := args["section"].(string); strings.TrimSpace(want) != "" { + start, end, idx, ok := resolveSection(raw, sections, want) + if !ok { + var b2 strings.Builder + b2.WriteString("没有匹配的章节。可用的章节:\n") + b2.WriteString(renderTOC(sections, 3)) + return nil, fmt.Errorf("skill %q 没有匹配 %q 的章节\n%s", + name, want, b2.String()) + } + lines := strings.Split(raw, "\n") + body := strings.Join(lines[start:end], "\n") + fmt.Fprintf(&b, "\n--- %s(第 %d-%d 行)---\n%s\n", sections[idx].Title, start+1, end, body) + return b.String(), nil + } + if tools := e.sk.Tools(); len(tools) > 0 { b.WriteString("文档中声明的操作步骤:\n") for _, td := range tools { fmt.Fprintf(&b, " - %s: %s\n", td.Name, td.Description) } } - fmt.Fprintf(&b, "\n--- SKILL.md ---\n%s", e.sk.RawContent()) + + // 短文档(<= shortSkillLines)直接给全文:加一层目录只是噪音。 + lines := strings.Split(raw, "\n") + if len(lines) <= shortSkillLines { + fmt.Fprintf(&b, "\n--- SKILL.md ---\n%s", raw) + return b.String(), nil + } + + // 长文档:先给目录 + 前若干行,模型据此决定读哪一节。 + fmt.Fprintf(&b, "\n--- 目录(本文 %d 行,请用 section 参数按需读取)---\n", len(lines)) + b.WriteString(renderTOC(sections, 3)) + fmt.Fprintf(&b, "\n--- 开头 %d 行 ---\n%s\n", tocHeadLines, + strings.Join(lines[:minInt(tocHeadLines, len(lines))], "\n")) return b.String(), nil }) }