diff --git a/CHANGELOG.md b/CHANGELOG.md index 8809332bb..0219cdc26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.clawd.bot - Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock - Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands - Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg. +- BlueBubbles: add newline chunking mode option for streaming. (#1645) Thanks @tyler6204. ### Fixes - BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing. diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index d2aeb4022..66138dad2 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -38,7 +38,7 @@ export type BlueBubblesAccountConfig = { dms?: Record; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; - /** Chunking mode: "newline" (default) splits on every newline; "length" splits by size. */ + /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ chunkMode?: "length" | "newline"; blockStreaming?: boolean; /** Merge streamed block replies before sending. */ diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index 7a4b41d0e..5471fb825 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -246,10 +246,10 @@ describe("chunkByNewline", () => { expect(chunks).toEqual(["Line one", "Line two", "Line three"]); }); - it("filters empty lines", () => { + it("preserves blank lines by folding into the next chunk", () => { const text = "Line one\n\n\nLine two\n\nLine three"; const chunks = chunkByNewline(text, 1000); - expect(chunks).toEqual(["Line one", "Line two", "Line three"]); + expect(chunks).toEqual(["Line one", "\n\nLine two", "\nLine three"]); }); it("trims whitespace from lines", () => { @@ -258,6 +258,12 @@ describe("chunkByNewline", () => { expect(chunks).toEqual(["Line one", "Line two"]); }); + it("preserves leading blank lines on the first chunk", () => { + const text = "\n\nLine one\nLine two"; + const chunks = chunkByNewline(text, 1000); + expect(chunks).toEqual(["\n\nLine one", "Line two"]); + }); + it("falls back to length-based for long lines", () => { const text = "Short line\n" + "a".repeat(50) + "\nAnother short"; const chunks = chunkByNewline(text, 20); diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index 281612e37..ce604fbff 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -116,19 +116,34 @@ export function resolveChunkMode( */ export function chunkByNewline(text: string, maxLineLength: number): string[] { if (!text) return []; + if (maxLineLength <= 0) return text.trim() ? [text] : []; const lines = text.split("\n"); const chunks: string[] = []; + let pendingBlankLines = 0; for (const line of lines) { const trimmed = line.trim(); - if (!trimmed) continue; // skip empty lines + if (!trimmed) { + pendingBlankLines += 1; + continue; + } - if (trimmed.length <= maxLineLength) { - chunks.push(trimmed); - } else { - // Long line: fall back to length-based chunking - const subChunks = chunkText(trimmed, maxLineLength); - chunks.push(...subChunks); + const maxPrefix = Math.max(0, maxLineLength - 1); + const cappedBlankLines = pendingBlankLines > 0 ? Math.min(pendingBlankLines, maxPrefix) : 0; + const prefix = cappedBlankLines > 0 ? "\n".repeat(cappedBlankLines) : ""; + pendingBlankLines = 0; + + if (trimmed.length + prefix.length <= maxLineLength) { + chunks.push(prefix + trimmed); + continue; + } + + const firstLimit = Math.max(1, maxLineLength - prefix.length); + const first = trimmed.slice(0, firstLimit); + chunks.push(prefix + first); + const remaining = trimmed.slice(firstLimit); + if (remaining) { + chunks.push(...chunkText(remaining, maxLineLength)); } }