thought-dump.ts 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. /**
  2. * Gemini 2.5/3.x thought summaries often arrive as ordinary text parts
  3. * (no `thought: true`), shaped like:
  4. *
  5. * **Defining the Request**
  6. * The user wants the full text for Chapter 14...
  7. *
  8. * **Pinpointing Chapter Details**
  9. * ...
  10. *
  11. * Standard/strict chapter workflows concatenate every `onToken` into the
  12. * chapter body, so those English planning notes leak into the editor.
  13. * Strip them; do not treat Title-Case markdown headers as story text.
  14. */
  15. const CJK_RE = /[\u4e00-\u9fff]/
  16. const DUMP_HEADER_RE = /^\*\*([^*]+)\*\*\s*$/
  17. const DUMP_PROSE_RE =
  18. /^(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
  19. function isThoughtDumpHeader(line: string): boolean {
  20. const match = line.trim().match(DUMP_HEADER_RE)
  21. if (!match) return false
  22. const inner = match[1].trim()
  23. if (!inner || CJK_RE.test(inner)) return false
  24. if (!/^[A-Za-z]/.test(inner)) return false
  25. if (inner.length < 3 || inner.length > 80) return false
  26. if (!/^[A-Za-z0-9 ,:'\-()/]+$/.test(inner)) return false
  27. const words = inner.split(/\s+/).filter(Boolean)
  28. if (words.length === 0) return false
  29. if (words.length === 1) return /^[A-Z][a-z]+/.test(words[0] ?? "")
  30. const capitalized = words.filter((word) => /^[A-Z]/.test(word)).length
  31. return capitalized >= Math.ceil(words.length * 0.5)
  32. }
  33. function isEnglishDumpProse(text: string): boolean {
  34. const trimmed = text.trim()
  35. if (!trimmed || CJK_RE.test(trimmed)) return false
  36. return DUMP_PROSE_RE.test(trimmed)
  37. }
  38. function isMostlyEnglishProse(text: string): boolean {
  39. const trimmed = text.trim()
  40. if (!trimmed || CJK_RE.test(trimmed)) return false
  41. const letters = trimmed.match(/[A-Za-z]/g)?.length ?? 0
  42. const nonSpace = trimmed.replace(/\s/g, "").length
  43. return letters >= 12 && letters / Math.max(nonSpace, 1) >= 0.7
  44. }
  45. function looksLikeThoughtDumpBlock(block: string): boolean {
  46. const trimmed = block.trim()
  47. if (!trimmed || CJK_RE.test(trimmed)) return false
  48. const firstLine = trimmed.split("\n")[0] ?? ""
  49. if (isThoughtDumpHeader(firstLine)) return true
  50. return isEnglishDumpProse(trimmed)
  51. }
  52. export function isThoughtDumpText(text: string): boolean {
  53. const trimmed = text.trim()
  54. if (!trimmed || CJK_RE.test(trimmed)) return false
  55. if (looksLikeThoughtDumpBlock(trimmed)) return true
  56. const headerCount = trimmed.split("\n").filter((line) => isThoughtDumpHeader(line)).length
  57. return headerCount >= 2
  58. }
  59. function isLeadingDumpParagraph(block: string, alreadyInDump: boolean): boolean {
  60. if (looksLikeThoughtDumpBlock(block)) return true
  61. return alreadyInDump && isMostlyEnglishProse(block)
  62. }
  63. function stripLeadingThoughtDumpDense(text: string): string {
  64. if (!/^\s*\*\*[A-Za-z]/.test(text) && !DUMP_PROSE_RE.test(text.trim())) {
  65. return text.trim()
  66. }
  67. const lines = text.split("\n")
  68. const firstCjk = lines.findIndex((line) => CJK_RE.test(line))
  69. if (firstCjk < 0) {
  70. return isThoughtDumpText(text) || isMostlyEnglishProse(text) ? "" : text.trim()
  71. }
  72. let keepFrom = firstCjk
  73. while (keepFrom > 0 && !lines[keepFrom - 1]!.trim()) keepFrom -= 1
  74. const prefix = lines.slice(0, keepFrom).join("\n")
  75. if (!prefix.trim()) return text.trim()
  76. if (!isThoughtDumpText(prefix) && !looksLikeThoughtDumpBlock(prefix)) {
  77. return text.trim()
  78. }
  79. return lines.slice(keepFrom).join("\n").trim()
  80. }
  81. export function stripThoughtDumpFromText(text: string): string {
  82. if (!text) return text
  83. const normalized = text.replace(/\r\n?/g, "\n")
  84. const parts = normalized.split(/\n{2,}/)
  85. let start = 0
  86. let inDump = false
  87. while (start < parts.length && isLeadingDumpParagraph(parts[start]!, inDump)) {
  88. inDump = true
  89. start += 1
  90. }
  91. let end = parts.length
  92. inDump = false
  93. while (end > start) {
  94. const block = parts[end - 1]!
  95. if (looksLikeThoughtDumpBlock(block) || (inDump && isMostlyEnglishProse(block))) {
  96. inDump = true
  97. end -= 1
  98. continue
  99. }
  100. break
  101. }
  102. if (start >= end) {
  103. return stripLeadingThoughtDumpDense(normalized)
  104. }
  105. if (start === 0 && end === parts.length) {
  106. return stripLeadingThoughtDumpDense(normalized)
  107. }
  108. return parts.slice(start, end).join("\n\n").trim()
  109. }