浏览代码

fix(writing): 剥离 Gemini 思考摘要,避免标准/严格模式写入英文 CoT (#74)

Gemini 3.x 的 thought summary 经常作为普通 text part 返回(无 thought: true),
标准和严格工作流会把 onToken 拼进章节终稿。请求侧关闭 includeThoughts,
解析时跳过摘要 part,落盘前再剥一遍英文规划段。

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: darknessomi <darknessomi@users.noreply.github.com>
darknessomi 3 周之前
父节点
当前提交
6eaf27c46d

+ 49 - 1
src/lib/llm-providers.spec.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest"
-import { getCustomCompatibleHeaders, getProviderConfig, withCustomOriginHeader } from "./llm-providers"
+import { getCustomCompatibleHeaders, getProviderConfig, parseGoogleLine, withCustomOriginHeader } from "./llm-providers"
 import type { LlmConfig, ReasoningMode } from "@/stores/wiki-store"
 
 function customConfig(overrides: Partial<LlmConfig> = {}): LlmConfig {
@@ -657,3 +657,51 @@ describe("Qwen3.5/3.6 leading system coalesce", () => {
     expect(messages.map((message) => message.role)).toEqual(["system", "system", "user"])
   })
 })
+
+describe("Gemini thought summaries", () => {
+  function googleConfig(overrides: Partial<LlmConfig> = {}): LlmConfig {
+    return customConfig({
+      provider: "google",
+      model: "gemini-3.7-flash-preview",
+      ...overrides,
+    })
+  }
+
+  it("does not return thought:true parts as visible content", () => {
+    const line = 'data: {"candidates":[{"content":{"parts":[{"text":"先拆章纲","thought":true},{"text":"雨还在下。"}]}}]}'
+    expect(parseGoogleLine(line)).toBe("雨还在下。")
+  })
+
+  it("does not return unmarked thought-summary parts as visible content", () => {
+    const dump = "**Defining the Request**\\n\\nThe user wants the full text for Chapter 14."
+    const line = `data: {"candidates":[{"content":{"parts":[{"text":"${dump}"},{"text":"雨还在下。"}]}}]}`
+    expect(parseGoogleLine(line)).toBe("雨还在下。")
+  })
+
+  it("hides thought summaries on Gemini 3.x even in auto reasoning mode", () => {
+    const body = getProviderConfig(googleConfig()).buildBody(
+      [{ role: "user", content: "写第14章" }],
+    ) as { generationConfig?: { thinkingConfig?: Record<string, unknown> } }
+
+    expect(body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false })
+  })
+
+  it("keeps thinkingBudget:0 and still hides thoughts when reasoning is off", () => {
+    const body = getProviderConfig(googleConfig({ reasoning: { mode: "off" } })).buildBody(
+      [{ role: "user", content: "写第14章" }],
+    ) as { generationConfig?: { thinkingConfig?: Record<string, unknown> } }
+
+    expect(body.generationConfig?.thinkingConfig).toEqual({
+      thinkingBudget: 0,
+      includeThoughts: false,
+    })
+  })
+
+  it("does not send thinkingConfig for models that do not support it", () => {
+    const body = getProviderConfig(googleConfig({ model: "gemini-1.5-pro" })).buildBody(
+      [{ role: "user", content: "写第14章" }],
+    ) as Record<string, unknown>
+
+    expect(body.generationConfig).toBeUndefined()
+  })
+})

+ 27 - 4
src/lib/llm-providers.ts

@@ -11,6 +11,7 @@ import {
 } from "@/lib/llm-context-size"
 import { RESPONSE_RESERVE_FRAC } from "./context-budget"
 import type { LlmUsage } from "./llm-usage"
+import { isThoughtDumpText } from "./thought-dump"
 import type { UserMemorySurface } from "./user-memory/types"
 
 /**
@@ -441,7 +442,11 @@ export function parseGoogleLine(line: string): string | null {
     let out = ""
     for (const p of parts) {
       if (p.thought) continue
-      if (p.text) out += p.text
+      if (!p.text) continue
+      // Gemini 3.x thought summaries often arrive as ordinary text parts
+      // with no `thought: true`. Drop those so they never enter onToken.
+      if (isThoughtDumpText(p.text)) continue
+      out += p.text
     }
     return out.length > 0 ? out : null
   } catch {
@@ -1144,9 +1149,23 @@ function flattenGoogleSystemParts(content: string | ContentBlock[]): string {
   return content.map((b) => (b.type === "text" ? b.text : "")).join("")
 }
 
+export function googleModelSupportsThinkingConfig(model: string): boolean {
+  const normalized = model.toLowerCase()
+  return /gemini-(2\.5|3(?:\.\d+)?|exp)/i.test(normalized) || /thinking/i.test(normalized)
+}
+
+function withHiddenGoogleThoughts(
+  thinkingConfig: Record<string, unknown>,
+  model?: string,
+): Record<string, unknown> {
+  if (!googleModelSupportsThinkingConfig(model ?? "")) return thinkingConfig
+  return { ...thinkingConfig, includeThoughts: false }
+}
+
 function buildGoogleBody(
   messages: ChatMessage[],
   overrides?: RequestOverrides,
+  model?: string,
 ): Record<string, unknown> {
   const systemMessages = messages.filter((m) => m.role === "system")
   const conversationMessages = messages.filter((m) => m.role !== "system")
@@ -1185,7 +1204,7 @@ function buildGoogleBody(
     generationConfig.stopSequences = Array.isArray(overrides.stop) ? overrides.stop : [overrides.stop]
   }
   if (overrides?.reasoning?.mode === "off") {
-    generationConfig.thinkingConfig = { thinkingBudget: 0 }
+    generationConfig.thinkingConfig = withHiddenGoogleThoughts({ thinkingBudget: 0 }, model)
   } else if (overrides?.reasoning && overrides.reasoning.mode !== "auto") {
     const budget =
       overrides.reasoning.mode === "custom" && overrides.reasoning.budgetTokens !== undefined
@@ -1195,7 +1214,11 @@ function buildGoogleBody(
           : overrides.reasoning.mode === "medium"
             ? 4096
             : 8192
-    generationConfig.thinkingConfig = { thinkingBudget: budget }
+    generationConfig.thinkingConfig = withHiddenGoogleThoughts({ thinkingBudget: budget }, model)
+  } else if (googleModelSupportsThinkingConfig(model ?? "")) {
+    // Gemini 3.x thinks by default. Keep internal thinking, but do not
+    // return thought summaries — they otherwise leak into chapter text.
+    generationConfig.thinkingConfig = { includeThoughts: false }
   }
 
   return {
@@ -1255,7 +1278,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
         buildBody: (messages, overrides) => buildGoogleBody(messages, {
           ...(overrides ?? {}),
           reasoning: effectiveReasoning(config, overrides),
-        }),
+        }, model),
         parseStream: parseGoogleLine,
         parseUsage: parseGoogleUsage,
         parseFinishReason: parseGoogleFinishReason,

+ 26 - 0
src/lib/novel/chapter-content-cleanup.spec.ts

@@ -107,4 +107,30 @@ describe("cleanGeneratedChapterContentForDisplay", () => {
   it("清洗后为空时退回原文,避免出现空气泡", () => {
     expect(cleanGeneratedChapterContentForDisplay("正文:")).toBe("正文:")
   })
+
+  it("剥掉 Gemini 思考摘要,不把英文规划写进章节展示", () => {
+    const dumped = [
+      "**Defining the Request**",
+      "",
+      "The user wants the full text for Chapter 14.",
+      "",
+      "**Pinpointing Chapter Details**",
+      "",
+      "I need to keep Ye Ren in Black Water Alley.",
+      "",
+      "第14章 世界真相与淬体破限",
+      "",
+      "雨还在下。黑水巷7号的铁门没有关严。",
+    ].join("\n")
+    expect(cleanGeneratedChapterContentForDisplay(dumped)).toBe(
+      "第14章 世界真相与淬体破限\n\n雨还在下。黑水巷7号的铁门没有关严。",
+    )
+    expect(cleanGeneratedChapterContentForSave(dumped)).toBe("雨还在下。黑水巷7号的铁门没有关严。")
+  })
+
+  it("整段都是思考摘要时不把英文退回气泡", () => {
+    expect(cleanGeneratedChapterContentForDisplay(
+      "**Defining the Request**\n\nThe user wants the full text for Chapter 14.",
+    )).toBe("")
+  })
 })

+ 11 - 2
src/lib/novel/chapter-content-cleanup.ts

@@ -1,3 +1,5 @@
+import { isThoughtDumpText, stripThoughtDumpFromText } from "@/lib/thought-dump"
+
 function stripThinkingBlocks(content: string): string {
   let result = content
   // 1. 移除完整的 <think>...</think> 或 <thinking>...</thinking> 块
@@ -149,7 +151,8 @@ function cleanChapterContentCore(
   content: string,
   options: { dropTrailingOffer: boolean },
 ): CleanedChapterContent {
-  const withoutThinking = stripThinkingBlocks(content).replace(/\r\n?/g, "\n")
+  const withoutThoughtDump = stripThoughtDumpFromText(content)
+  const withoutThinking = stripThinkingBlocks(withoutThoughtDump).replace(/\r\n?/g, "\n")
   const withoutCitations = stripCitationSyntax(withoutThinking)
   // 标签先剥,否则「正文:」挡在前面会让章节标题识别不到。
   const allLines = stripLeadingBodyLabel(withoutCitations.split("\n"))
@@ -199,7 +202,13 @@ export function cleanGeneratedChapterContentWithTitle(content: string): CleanedC
  */
 export function cleanGeneratedChapterContentForDisplay(content: string): string {
   const { content: body, title } = cleanChapterContentCore(content, { dropTrailingOffer: false })
-  if (!body.trim()) return content.trim()
+  if (!body.trim()) {
+    // Thought dumps must not bounce back as the chapter bubble.
+    if (isThoughtDumpText(content) || !stripThoughtDumpFromText(content).trim()) {
+      return ""
+    }
+    return content.trim()
+  }
   if (!title) return body
   // 章节草稿要求首行是「# 第X章 标题」,按原样保留标题行的 Markdown 形态。
   const originalTitleLine = content

+ 47 - 0
src/lib/novel/deep-chapter-generation.spec.ts

@@ -1452,6 +1452,53 @@ describe("runDeepChapterGeneration", () => {
     })
   })
 
+  it("strips Gemini thought summaries out of standard-mode chapter drafts", async () => {
+    const deps = createDeps()
+    const dump = [
+      "**Defining the Request**",
+      "",
+      "The user wants the full text for Chapter 14.",
+      "",
+      "**Pinpointing Chapter Details**",
+      "",
+      "I need to keep Ye Ren in Black Water Alley.",
+    ].join("\n")
+    vi.mocked(deps.streamChat).mockImplementation(async (
+      _config: LlmConfig,
+      messages: ChatMessage[],
+      callbacks: StreamCallbacks,
+    ) => {
+      const prompt = messagesPromptText(messages)
+      const body = prompt.includes("简单审查") || prompt.includes("去AI味")
+        ? chapterText("最终兜底正文", 3000)
+        : prompt.includes("返修")
+          ? chapterText("返修兜底正文", 3000)
+          : prompt.includes("正文")
+            ? chapterText("初稿兜底正文", 3000)
+            : "写作任务书内容"
+      callbacks.onToken(body === "写作任务书内容" ? body : `${dump}\n\n${body}`)
+      callbacks.onDone()
+    })
+
+    const result = await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第三章",
+        chapterNumber: 3,
+        llmConfig,
+        aiWorkflowMode: "standard",
+      },
+      {},
+      deps,
+    )
+
+    expect(result.draftContent).toContain("初稿兜底正文")
+    expect(result.draftContent).not.toContain("Defining the Request")
+    expect(result.finalContent).not.toContain("Defining the Request")
+    expect(result.finalContent).not.toContain("The user wants")
+    expect(result.finalContent.length).toBeGreaterThan(500)
+  })
+
   it("uses fast, standard, and strict workflow routes", async () => {
     const skippedCollect = vi.fn(async () => ({ markdown: "", searchedNames: [], notes: [] }))
     const fastDeps = { ...createDeps(), collectWritingEntityWebSearch: skippedCollect }

+ 16 - 7
src/lib/novel/deep-chapter-generation.ts

@@ -23,6 +23,7 @@ import {
   thinkingMinMaxTokens,
 } from "@/lib/llm-providers";
 import { USER_ABORT_MESSAGE, rethrowIfUserAbort, throwIfAborted } from "@/lib/user-abort";
+import { isThoughtDumpText, stripThoughtDumpFromText } from "@/lib/thought-dump";
 import {
   buildContextPack,
   contextPackToPrompt,
@@ -1956,13 +1957,14 @@ async function collectModelText(
       const loopStart = findRepeatedTailStart(content);
       if (loopStart !== null) {
         content = content.slice(0, loopStart).trimEnd();
+        const visible = stripThoughtDumpFromText(content) || content;
         onUpdate?.(
-          `${content}\n\n(已检测到模型重复输出,已自动停止重复内容。)`,
+          `${visible}\n\n(已检测到模型重复输出,已自动停止重复内容。)`,
         );
         stopStream("检测到模型重复输出,已自动停止重复内容。");
         return;
       }
-      onUpdate?.(content);
+      onUpdate?.(stripThoughtDumpFromText(content) || content);
     },
     onReasoningToken: (token) => {
       if (signal?.aborted) {
@@ -1996,10 +1998,16 @@ async function collectModelText(
 
   await streamOnce(requestOverrides);
 
+  const hasThoughtDumpOnly =
+    !streamError &&
+    !stripThoughtDumpFromText(content).trim() &&
+    (Boolean(reasoningBuffer.trim()) || isThoughtDumpText(content));
   if (
-    streamError &&
-    isReasoningOnlyResponseError(streamError) &&
-    !isReasoningDisabled(config, requestOverrides)
+    !isReasoningDisabled(config, requestOverrides) &&
+    (
+      (streamError && isReasoningOnlyResponseError(streamError)) ||
+      hasThoughtDumpOnly
+    )
   ) {
     content = "";
     reasoningBuffer = "";
@@ -2010,10 +2018,11 @@ async function collectModelText(
   if (signal?.aborted) throw new Error(USER_ABORT_MESSAGE);
   if (streamError && !(cutoffReason && isRequestCancelledError(streamError)))
     throw streamError;
+  const visible = stripThoughtDumpFromText(content).trim();
   if (cutoffReason) {
-    onUpdate?.(`${content.trim()}\n\n(${cutoffReason})`);
+    onUpdate?.(`${visible || content.trim()}\n\n(${cutoffReason})`);
   }
-  return content.trim();
+  return visible;
 }
 
 function countChapterChars(content: string): number {

+ 15 - 0
src/lib/reasoning-detector.spec.ts

@@ -34,4 +34,19 @@ describe("reasoning detector", () => {
 
     expect(extractReasoningTextFromLine(line)).toEqual(["调用工具"])
   })
+
+  it("extracts Gemini thought:true parts", () => {
+    const line = 'data: {"candidates":[{"content":{"parts":[{"text":"先拆章纲","thought":true},{"text":"雨还在下。"}]}}]}'
+
+    expect(extractReasoningTextFromLine(line)).toEqual(["先拆章纲"])
+  })
+
+  it("extracts unmarked Gemini thought-summary parts as reasoning", () => {
+    const dump = "**Defining the Request**\\n\\nThe user wants the full text for Chapter 14."
+    const line = `data: {"candidates":[{"content":{"parts":[{"text":"${dump}"},{"text":"雨还在下。"}]}}]}`
+
+    expect(extractReasoningTextFromLine(line)).toEqual([
+      "**Defining the Request**\n\nThe user wants the full text for Chapter 14.",
+    ])
+  })
 })

+ 4 - 1
src/lib/reasoning-detector.ts

@@ -36,6 +36,8 @@
  * exact tokens.
  */
 
+import { isThoughtDumpText } from "./thought-dump"
+
 const REASONING_FIELD_RE =
   /"reasoning(?:_content)?"\s*:\s*"((?:[^"\\]|\\.)*)"/g
 
@@ -99,7 +101,8 @@ export function extractReasoningTextFromLine(rawLine: string): string[] {
 
     for (const candidate of parsed.candidates ?? []) {
       for (const part of candidate.content?.parts ?? []) {
-        if (part.thought && typeof part.text === "string") out.push(part.text)
+        if (typeof part.text !== "string" || !part.text) continue
+        if (part.thought || isThoughtDumpText(part.text)) out.push(part.text)
       }
     }
 

+ 80 - 0
src/lib/thought-dump.spec.ts

@@ -0,0 +1,80 @@
+import { describe, expect, it } from "vitest"
+import {
+  isThoughtDumpText,
+  stripThoughtDumpFromText,
+} from "./thought-dump"
+
+const GEMINI_THOUGHT_DUMP = [
+  "**Defining the Request**",
+  "",
+  "The user wants the full text for Chapter 14, World Truth and Body Forging Limits.",
+  "",
+  "**Pinpointing Chapter Details**",
+  "",
+  "I need to keep Black Water Alley, Ye Ren, and Hakimi, and hit the requested word count.",
+  "",
+  "**Analyzing Chapter Content**",
+  "",
+  "The setting still has tech decay, lifeform classifications, and the cultivation system.",
+  "",
+  "**Refining Plot Elements**",
+  "",
+  "The investigative thread should stay on Jin Yulan and Zhao Chongshan.",
+  "",
+  "**Detailing Scene Progression**",
+  "",
+  "The opening scene starts in the rain outside Black Water Alley.",
+].join("\n")
+
+describe("isThoughtDumpText", () => {
+  it("recognizes Gemini thought-summary dumps", () => {
+    expect(isThoughtDumpText(GEMINI_THOUGHT_DUMP)).toBe(true)
+  })
+
+  it("does not treat Chinese chapter text as a dump", () => {
+    expect(isThoughtDumpText("雨还在下。黑水巷7号的铁门没有关严。")).toBe(false)
+  })
+
+  it("does not treat ordinary English prose without dump headers as a dump", () => {
+    expect(isThoughtDumpText("It was a dark and stormy night.\n\nThe detective walked into the alley.")).toBe(false)
+  })
+})
+
+describe("stripThoughtDumpFromText", () => {
+  it("drops a leading Gemini thought dump and keeps the Chinese chapter", () => {
+    const chapter = [
+      GEMINI_THOUGHT_DUMP,
+      "",
+      "第14章 世界真相与淬体破限",
+      "",
+      "雨还在下。黑水巷7号的铁门没有关严,门缝里渗出一截湿冷的灯光。",
+    ].join("\n")
+
+    expect(stripThoughtDumpFromText(chapter)).toBe([
+      "第14章 世界真相与淬体破限",
+      "",
+      "雨还在下。黑水巷7号的铁门没有关严,门缝里渗出一截湿冷的灯光。",
+    ].join("\n"))
+  })
+
+  it("returns empty when the whole payload is a thought dump", () => {
+    expect(stripThoughtDumpFromText(GEMINI_THOUGHT_DUMP)).toBe("")
+  })
+
+  it("strips a dump glued to Chinese without blank-line section breaks", () => {
+    const glued = [
+      "**Defining the Request**",
+      "The user wants the full text for Chapter 14.",
+      "**Pinpointing Chapter Details**",
+      "I need to keep Ye Ren in Black Water Alley.",
+      "雨还在下。叶刃把伞骨收紧。",
+    ].join("\n")
+
+    expect(stripThoughtDumpFromText(glued)).toBe("雨还在下。叶刃把伞骨收紧。")
+  })
+
+  it("keeps a Chinese chapter that uses bold emphasis", () => {
+    const chapter = "**夜雨**\n\n他走进巷子,没有回头。"
+    expect(stripThoughtDumpFromText(chapter)).toBe(chapter)
+  })
+})

+ 122 - 0
src/lib/thought-dump.ts

@@ -0,0 +1,122 @@
+/**
+ * Gemini 2.5/3.x thought summaries often arrive as ordinary text parts
+ * (no `thought: true`), shaped like:
+ *
+ *   **Defining the Request**
+ *   The user wants the full text for Chapter 14...
+ *
+ *   **Pinpointing Chapter Details**
+ *   ...
+ *
+ * Standard/strict chapter workflows concatenate every `onToken` into the
+ * chapter body, so those English planning notes leak into the editor.
+ * Strip them; do not treat Title-Case markdown headers as story text.
+ */
+
+const CJK_RE = /[\u4e00-\u9fff]/
+const DUMP_HEADER_RE = /^\*\*([^*]+)\*\*\s*$/
+const DUMP_PROSE_RE =
+  /^(The user (wants|is asking|requested|needs|has asked)|I need to|I'll |I will |Let's |Let me |The request\b|The goal\b|The task\b)/i
+
+export function isThoughtDumpHeader(line: string): boolean {
+  const match = line.trim().match(DUMP_HEADER_RE)
+  if (!match) return false
+  const inner = match[1].trim()
+  if (!inner || CJK_RE.test(inner)) return false
+  if (!/^[A-Za-z]/.test(inner)) return false
+  if (inner.length < 3 || inner.length > 80) return false
+  if (!/^[A-Za-z0-9 ,:'\-()/]+$/.test(inner)) return false
+  const words = inner.split(/\s+/).filter(Boolean)
+  if (words.length === 0) return false
+  if (words.length === 1) return /^[A-Z][a-z]+/.test(words[0] ?? "")
+  const capitalized = words.filter((word) => /^[A-Z]/.test(word)).length
+  return capitalized >= Math.ceil(words.length * 0.5)
+}
+
+function isEnglishDumpProse(text: string): boolean {
+  const trimmed = text.trim()
+  if (!trimmed || CJK_RE.test(trimmed)) return false
+  return DUMP_PROSE_RE.test(trimmed)
+}
+
+function isMostlyEnglishProse(text: string): boolean {
+  const trimmed = text.trim()
+  if (!trimmed || CJK_RE.test(trimmed)) return false
+  const letters = trimmed.match(/[A-Za-z]/g)?.length ?? 0
+  const nonSpace = trimmed.replace(/\s/g, "").length
+  return letters >= 12 && letters / Math.max(nonSpace, 1) >= 0.7
+}
+
+export function looksLikeThoughtDumpBlock(block: string): boolean {
+  const trimmed = block.trim()
+  if (!trimmed || CJK_RE.test(trimmed)) return false
+  const firstLine = trimmed.split("\n")[0] ?? ""
+  if (isThoughtDumpHeader(firstLine)) return true
+  return isEnglishDumpProse(trimmed)
+}
+
+export function isThoughtDumpText(text: string): boolean {
+  const trimmed = text.trim()
+  if (!trimmed || CJK_RE.test(trimmed)) return false
+  if (looksLikeThoughtDumpBlock(trimmed)) return true
+  const headerCount = trimmed.split("\n").filter((line) => isThoughtDumpHeader(line)).length
+  return headerCount >= 2
+}
+
+function isLeadingDumpParagraph(block: string, alreadyInDump: boolean): boolean {
+  if (looksLikeThoughtDumpBlock(block)) return true
+  return alreadyInDump && isMostlyEnglishProse(block)
+}
+
+function stripLeadingThoughtDumpDense(text: string): string {
+  if (!/^\s*\*\*[A-Za-z]/.test(text) && !DUMP_PROSE_RE.test(text.trim())) {
+    return text.trim()
+  }
+
+  const lines = text.split("\n")
+  const firstCjk = lines.findIndex((line) => CJK_RE.test(line))
+  if (firstCjk < 0) {
+    return isThoughtDumpText(text) || isMostlyEnglishProse(text) ? "" : text.trim()
+  }
+
+  let keepFrom = firstCjk
+  while (keepFrom > 0 && !lines[keepFrom - 1]!.trim()) keepFrom -= 1
+  const prefix = lines.slice(0, keepFrom).join("\n")
+  if (!prefix.trim()) return text.trim()
+  if (!isThoughtDumpText(prefix) && !looksLikeThoughtDumpBlock(prefix)) {
+    return text.trim()
+  }
+  return lines.slice(keepFrom).join("\n").trim()
+}
+
+export function stripThoughtDumpFromText(text: string): string {
+  if (!text) return text
+  const normalized = text.replace(/\r\n?/g, "\n")
+  const parts = normalized.split(/\n{2,}/)
+
+  let start = 0
+  let inDump = false
+  while (start < parts.length && isLeadingDumpParagraph(parts[start]!, inDump)) {
+    inDump = true
+    start += 1
+  }
+  let end = parts.length
+  inDump = false
+  while (end > start) {
+    const block = parts[end - 1]!
+    if (looksLikeThoughtDumpBlock(block) || (inDump && isMostlyEnglishProse(block))) {
+      inDump = true
+      end -= 1
+      continue
+    }
+    break
+  }
+
+  if (start >= end) {
+    return stripLeadingThoughtDumpDense(normalized)
+  }
+  if (start === 0 && end === parts.length) {
+    return stripLeadingThoughtDumpDense(normalized)
+  }
+  return parts.slice(start, end).join("\n\n").trim()
+}