From 4e4ff506da43826bd6b7a3cd0e111b4b4a57cd97 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Sat, 24 Jan 2026 10:42:39 -0800 Subject: [PATCH] feat: add chunking mode for outbound messages - Introduced `chunkMode` option in various account configurations to allow splitting messages by "length" or "newline". - Updated message processing to handle chunking based on the selected mode. - Added tests for new chunking functionality, ensuring correct behavior for both modes. --- extensions/bluebubbles/src/config-schema.ts | 1 + extensions/bluebubbles/src/monitor.ts | 22 ++++- extensions/bluebubbles/src/types.ts | 2 + extensions/matrix/src/config-schema.ts | 1 + extensions/matrix/src/types.ts | 2 + extensions/mattermost/src/config-schema.ts | 1 + extensions/mattermost/src/types.ts | 2 + .../nextcloud-talk/src/config-schema.ts | 1 + extensions/nextcloud-talk/src/types.ts | 2 + src/auto-reply/chunk.test.ts | 95 ++++++++++++++++++- src/auto-reply/chunk.ts | 82 +++++++++++++++- src/auto-reply/reply/block-streaming.ts | 20 +++- src/config/types.discord.ts | 2 + src/config/types.imessage.ts | 2 + src/config/types.msteams.ts | 2 + src/config/types.signal.ts | 2 + src/config/types.slack.ts | 2 + src/config/types.telegram.ts | 2 + src/config/types.whatsapp.ts | 4 + src/config/zod-schema.providers-core.ts | 7 ++ src/config/zod-schema.providers-whatsapp.ts | 2 + src/infra/outbound/deliver.ts | 14 ++- src/plugin-sdk/index.ts | 1 + src/plugins/runtime/index.ts | 12 ++- src/plugins/runtime/types.ts | 6 ++ 25 files changed, 281 insertions(+), 8 deletions(-) diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index 844641b94..dc532e979 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -38,6 +38,7 @@ const bluebubblesAccountSchema = z.object({ historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), mediaMaxMb: z.number().int().positive().optional(), sendReadReceipts: z.boolean().optional(), blockStreaming: z.boolean().optional(), diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 570ca42e0..09c8359be 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1851,16 +1851,25 @@ async function processMessage( account.config.textChunkLimit && account.config.textChunkLimit > 0 ? account.config.textChunkLimit : DEFAULT_TEXT_LIMIT; + const chunkMode = core.channel.text.resolveChunkMode( + config, + "bluebubbles", + account.accountId, + ); const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg: config, channel: "bluebubbles", accountId: account.accountId, }); const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - const chunks = core.channel.text.chunkMarkdownText(text, textLimit); + const chunks = + chunkMode === "newline" + ? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode) + : core.channel.text.chunkMarkdownText(text, textLimit); if (!chunks.length && text) chunks.push(text); if (!chunks.length) return; - for (const chunk of chunks) { + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; const result = await sendMessageBlueBubbles(outboundTarget, chunk, { cfg: config, accountId: account.accountId, @@ -1869,6 +1878,15 @@ async function processMessage( maybeEnqueueOutboundMessageId(result.messageId, chunk); sentMessage = true; statusSink?.({ lastOutboundAt: Date.now() }); + // In newline mode, restart typing after each chunk if more chunks remain + if (chunkMode === "newline" && i < chunks.length - 1 && chatGuidForActions) { + sendBlueBubblesTyping(chatGuidForActions, true, { + cfg: config, + accountId: account.accountId, + }).catch(() => { + // Ignore typing errors + }); + } } }, onReplyStart: async () => { diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 6b1da775b..e4650ce33 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -38,6 +38,8 @@ export type BlueBubblesAccountConfig = { dms?: Record; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size, "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; blockStreaming?: boolean; /** Merge streamed block replies before sending. */ blockStreamingCoalesce?: Record; diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index b153ae40f..62b327d40 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -50,6 +50,7 @@ export const MatrixConfigSchema = z.object({ replyToMode: z.enum(["off", "first", "all"]).optional(), threadReplies: z.enum(["off", "inbound", "always"]).optional(), textChunkLimit: z.number().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), mediaMaxMb: z.number().optional(), autoJoin: z.enum(["always", "allowlist", "off"]).optional(), autoJoinAllowlist: z.array(allowFromEntry).optional(), diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index b7ff7facd..3848eb5ec 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -69,6 +69,8 @@ export type MatrixConfig = { threadReplies?: "off" | "inbound" | "always"; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size, "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; /** Max outbound media size in MB. */ mediaMaxMb?: number; /** Auto-join invites (always|allowlist|off). Default: always. */ diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 40ae8a31a..2a1b76248 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -25,6 +25,7 @@ const MattermostAccountSchemaBase = z groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), }) diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index a80196142..a51cb34a4 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -36,6 +36,8 @@ export type MattermostAccountConfig = { groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size, "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; /** Disable block streaming for this account. */ blockStreaming?: boolean; /** Merge streamed block replies before sending. */ diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index b047c7903..8eb5fa27b 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -44,6 +44,7 @@ export const NextcloudTalkAccountSchemaBase = z dmHistoryLimit: z.number().int().min(0).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), mediaMaxMb: z.number().positive().optional(), diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index 18525ccab..ea079bbd1 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -62,6 +62,8 @@ export type NextcloudTalkAccountConfig = { dms?: Record; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size, "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; /** Disable block streaming for this account. */ 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 17e98739c..c9ec0df3a 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { chunkMarkdownText, chunkText, resolveTextChunkLimit } from "./chunk.js"; +import { + chunkByNewline, + chunkMarkdownText, + chunkText, + chunkTextWithMode, + resolveChunkMode, + resolveTextChunkLimit, +} from "./chunk.js"; function expectFencesBalanced(chunks: string[]) { for (const chunk of chunks) { @@ -231,3 +238,89 @@ describe("chunkMarkdownText", () => { expect(chunks.join("")).toBe(text); }); }); + +describe("chunkByNewline", () => { + it("splits text on newlines", () => { + const text = "Line one\nLine two\nLine three"; + const chunks = chunkByNewline(text, 1000); + expect(chunks).toEqual(["Line one", "Line two", "Line three"]); + }); + + it("filters empty lines", () => { + 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"]); + }); + + it("trims whitespace from lines", () => { + const text = " Line one \n Line two "; + const chunks = chunkByNewline(text, 1000); + expect(chunks).toEqual(["Line 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); + expect(chunks[0]).toBe("Short line"); + // Long line gets split into multiple chunks + expect(chunks[1].length).toBe(20); + expect(chunks[2].length).toBe(20); + expect(chunks[3].length).toBe(10); + expect(chunks[4]).toBe("Another short"); + }); + + it("returns empty array for empty input", () => { + expect(chunkByNewline("", 100)).toEqual([]); + }); + + it("returns empty array for whitespace-only input", () => { + expect(chunkByNewline(" \n\n ", 100)).toEqual([]); + }); +}); + +describe("chunkTextWithMode", () => { + it("uses length-based chunking for length mode", () => { + const text = "Line one\nLine two"; + const chunks = chunkTextWithMode(text, 1000, "length"); + expect(chunks).toEqual(["Line one\nLine two"]); + }); + + it("uses newline-based chunking for newline mode", () => { + const text = "Line one\nLine two"; + const chunks = chunkTextWithMode(text, 1000, "newline"); + expect(chunks).toEqual(["Line one", "Line two"]); + }); +}); + +describe("resolveChunkMode", () => { + it("returns length as default", () => { + expect(resolveChunkMode(undefined, "telegram")).toBe("length"); + expect(resolveChunkMode({}, "discord")).toBe("length"); + }); + + it("returns length for internal channel", () => { + const cfg = { channels: { telegram: { chunkMode: "newline" as const } } }; + expect(resolveChunkMode(cfg, "__internal__")).toBe("length"); + }); + + it("supports provider-level overrides", () => { + const cfg = { channels: { telegram: { chunkMode: "newline" as const } } }; + expect(resolveChunkMode(cfg, "telegram")).toBe("newline"); + expect(resolveChunkMode(cfg, "discord")).toBe("length"); + }); + + it("supports account-level overrides", () => { + const cfg = { + channels: { + telegram: { + chunkMode: "length" as const, + accounts: { + primary: { chunkMode: "newline" as const }, + }, + }, + }, + }; + expect(resolveChunkMode(cfg, "telegram", "primary")).toBe("newline"); + expect(resolveChunkMode(cfg, "telegram", "other")).toBe("length"); + }); +}); diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index abbd830a2..054fa975b 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -10,11 +10,20 @@ import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; export type TextChunkProvider = ChannelId | typeof INTERNAL_MESSAGE_CHANNEL; +/** + * Chunking mode for outbound messages: + * - "length": Split only when exceeding textChunkLimit (default) + * - "newline": Split on every newline, with fallback to length-based for long lines + */ +export type ChunkMode = "length" | "newline"; + const DEFAULT_CHUNK_LIMIT = 4000; +const DEFAULT_CHUNK_MODE: ChunkMode = "length"; type ProviderChunkConfig = { textChunkLimit?: number; - accounts?: Record; + chunkMode?: ChunkMode; + accounts?: Record; }; function resolveChunkLimitForProvider( @@ -63,6 +72,77 @@ export function resolveTextChunkLimit( return fallback; } +function resolveChunkModeForProvider( + cfgSection: ProviderChunkConfig | undefined, + accountId?: string | null, +): ChunkMode | undefined { + if (!cfgSection) return undefined; + const normalizedAccountId = normalizeAccountId(accountId); + const accounts = cfgSection.accounts; + if (accounts && typeof accounts === "object") { + const direct = accounts[normalizedAccountId]; + if (direct?.chunkMode) { + return direct.chunkMode; + } + const matchKey = Object.keys(accounts).find( + (key) => key.toLowerCase() === normalizedAccountId.toLowerCase(), + ); + const match = matchKey ? accounts[matchKey] : undefined; + if (match?.chunkMode) { + return match.chunkMode; + } + } + return cfgSection.chunkMode; +} + +export function resolveChunkMode( + cfg: ClawdbotConfig | undefined, + provider?: TextChunkProvider, + accountId?: string | null, +): ChunkMode { + if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return DEFAULT_CHUNK_MODE; + const channelsConfig = cfg?.channels as Record | undefined; + const providerConfig = (channelsConfig?.[provider] ?? + (cfg as Record | undefined)?.[provider]) as ProviderChunkConfig | undefined; + const mode = resolveChunkModeForProvider(providerConfig, accountId); + return mode ?? DEFAULT_CHUNK_MODE; +} + +/** + * Split text on newlines, filtering empty lines. + * Lines exceeding maxLineLength are further split using length-based chunking. + */ +export function chunkByNewline(text: string, maxLineLength: number): string[] { + if (!text) return []; + const lines = text.split("\n"); + const chunks: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; // skip empty lines + + if (trimmed.length <= maxLineLength) { + chunks.push(trimmed); + } else { + // Long line: fall back to length-based chunking + const subChunks = chunkText(trimmed, maxLineLength); + chunks.push(...subChunks); + } + } + + return chunks; +} + +/** + * Unified chunking function that dispatches based on mode. + */ +export function chunkTextWithMode(text: string, limit: number, mode: ChunkMode): string[] { + if (mode === "newline") { + return chunkByNewline(text, limit); + } + return chunkText(text, limit); +} + export function chunkText(text: string, limit: number): string[] { if (!text) return []; if (limit <= 0) return [text]; diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index 52d114500..3a4abd564 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -7,7 +7,7 @@ import { INTERNAL_MESSAGE_CHANNEL, listDeliverableMessageChannels, } from "../../utils/message-channel.js"; -import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js"; +import { resolveChunkMode, resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js"; const DEFAULT_BLOCK_STREAM_MIN = 800; const DEFAULT_BLOCK_STREAM_MAX = 1200; @@ -68,6 +68,17 @@ export function resolveBlockStreamingChunking( fallbackLimit: providerChunkLimit, }); const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk; + + // Check if channel has chunkMode: "newline" - if so, use newline-based streaming + const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId); + if (channelChunkMode === "newline") { + // For newline mode: use very low minChars to flush quickly on newlines + const minChars = Math.max(1, Math.floor(chunkCfg?.minChars ?? 1)); + const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? textLimit)); + const maxChars = Math.max(1, Math.min(maxRequested, textLimit)); + return { minChars, maxChars, breakPreference: "newline" }; + } + const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX)); const maxChars = Math.max(1, Math.min(maxRequested, textLimit)); const minFallback = DEFAULT_BLOCK_STREAM_MIN; @@ -91,6 +102,13 @@ export function resolveBlockStreamingCoalescing( }, ): BlockStreamingCoalescing | undefined { const providerKey = normalizeChunkProvider(provider); + + // When chunkMode is "newline", disable coalescing entirely to send each line immediately + const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId); + if (channelChunkMode === "newline") { + return undefined; + } + const providerId = providerKey ? normalizeChannelId(providerKey) : null; const providerChunkLimit = providerId ? getChannelDock(providerId)?.outbound?.textChunkLimit diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index ae434dd15..167b56197 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -108,6 +108,8 @@ export type DiscordAccountConfig = { groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 2000. */ textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size, "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; /** Disable block streaming for this account. */ blockStreaming?: boolean; /** Merge streamed block replies before sending. */ diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index ca83c0fe0..249d8baae 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -54,6 +54,8 @@ export type IMessageAccountConfig = { mediaMaxMb?: number; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size, "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; blockStreaming?: boolean; /** Merge streamed block replies before sending. */ blockStreamingCoalesce?: BlockStreamingCoalesceConfig; diff --git a/src/config/types.msteams.ts b/src/config/types.msteams.ts index 05e27527a..956784b79 100644 --- a/src/config/types.msteams.ts +++ b/src/config/types.msteams.ts @@ -72,6 +72,8 @@ export type MSTeamsConfig = { groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size, "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; /** Merge streamed block replies before sending. */ blockStreamingCoalesce?: BlockStreamingCoalesceConfig; /** diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index 94cb82f3d..fcebf7273 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -56,6 +56,8 @@ export type SignalAccountConfig = { dms?: Record; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size, "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; blockStreaming?: boolean; /** Merge streamed block replies before sending. */ blockStreamingCoalesce?: BlockStreamingCoalesceConfig; diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index 0662bf36f..557ad62f3 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -116,6 +116,8 @@ export type SlackAccountConfig = { /** Per-DM config overrides keyed by user ID. */ dms?: Record; textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size, "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; blockStreaming?: boolean; /** Merge streamed block replies before sending. */ blockStreamingCoalesce?: BlockStreamingCoalesceConfig; diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 1ef7e7387..858eb5a56 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -80,6 +80,8 @@ export type TelegramAccountConfig = { dms?: Record; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size, "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; /** Disable block streaming for this account. */ blockStreaming?: boolean; /** Chunking config for draft streaming in `streamMode: "block"`. */ diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index ce1851ea0..b671ce86d 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -55,6 +55,8 @@ export type WhatsAppConfig = { dms?: Record; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size, "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; /** Maximum media file size in MB. Default: 50. */ mediaMaxMb?: number; /** Disable block streaming for this account. */ @@ -122,6 +124,8 @@ export type WhatsAppAccountConfig = { /** Per-DM config overrides keyed by user ID. */ dms?: Record; textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size, "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; mediaMaxMb?: number; blockStreaming?: boolean; /** Merge streamed block replies before sending. */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 2aee48711..b6681e652 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -102,6 +102,7 @@ export const TelegramAccountSchemaBase = z dmHistoryLimit: z.number().int().min(0).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), blockStreaming: z.boolean().optional(), draftChunk: BlockStreamingChunkSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), @@ -212,6 +213,7 @@ export const DiscordAccountSchema = z dmHistoryLimit: z.number().int().min(0).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), maxLinesPerMessage: z.number().int().positive().optional(), @@ -401,6 +403,7 @@ export const SlackAccountSchema = z dmHistoryLimit: z.number().int().min(0).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), mediaMaxMb: z.number().positive().optional(), @@ -494,6 +497,7 @@ export const SignalAccountSchemaBase = z dmHistoryLimit: z.number().int().min(0).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), mediaMaxMb: z.number().int().positive().optional(), @@ -547,6 +551,7 @@ export const IMessageAccountSchemaBase = z includeAttachments: z.boolean().optional(), mediaMaxMb: z.number().int().positive().optional(), textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), groups: z @@ -633,6 +638,7 @@ export const BlueBubblesAccountSchemaBase = z dmHistoryLimit: z.number().int().min(0).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), mediaMaxMb: z.number().int().positive().optional(), sendReadReceipts: z.boolean().optional(), blockStreaming: z.boolean().optional(), @@ -704,6 +710,7 @@ export const MSTeamsConfigSchema = z groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), mediaAllowHosts: z.array(z.string()).optional(), requireMention: z.boolean().optional(), diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 5a0d62379..7266f8bf6 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -30,6 +30,7 @@ export const WhatsAppAccountSchema = z dmHistoryLimit: z.number().int().min(0).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), mediaMaxMb: z.number().int().positive().optional(), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), @@ -85,6 +86,7 @@ export const WhatsAppConfigSchema = z dmHistoryLimit: z.number().int().min(0).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), mediaMaxMb: z.number().int().positive().optional().default(50), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 73f5550e0..015c38a33 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -1,4 +1,8 @@ -import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; +import { + chunkTextWithMode, + resolveChunkMode, + resolveTextChunkLimit, +} from "../../auto-reply/chunk.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js"; import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js"; @@ -192,6 +196,7 @@ export async function deliverOutboundPayloads(params: { fallbackLimit: handler.textChunkLimit, }) : undefined; + const chunkMode = resolveChunkMode(cfg, channel, accountId); const isSignalChannel = channel === "signal"; const signalTableMode = isSignalChannel ? resolveMarkdownTableMode({ cfg, channel: "signal", accountId }) @@ -212,7 +217,12 @@ export async function deliverOutboundPayloads(params: { results.push(await handler.sendText(text)); return; } - for (const chunk of handler.chunker(text, textLimit)) { + // Use newline chunking if explicitly configured, otherwise use the adapter's chunker + const chunks = + chunkMode === "newline" + ? chunkTextWithMode(text, textLimit, chunkMode) + : handler.chunker(text, textLimit); + for (const chunk of chunks) { throwIfAborted(abortSignal); results.push(await handler.sendText(chunk)); } diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index f40d99d82..cb4e95a82 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -112,6 +112,7 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { resolveAckReaction } from "../agents/identity.js"; export type { ReplyPayload } from "../auto-reply/types.js"; +export type { ChunkMode } from "../auto-reply/chunk.js"; export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js"; export { buildPendingHistoryContextFromMap, diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 5783711b1..50e0a2d03 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -1,6 +1,13 @@ import { createRequire } from "node:module"; -import { chunkMarkdownText, chunkText, resolveTextChunkLimit } from "../../auto-reply/chunk.js"; +import { + chunkByNewline, + chunkMarkdownText, + chunkText, + chunkTextWithMode, + resolveChunkMode, + resolveTextChunkLimit, +} from "../../auto-reply/chunk.js"; import { hasControlCommand, isControlCommandMessage, @@ -160,8 +167,11 @@ export function createPluginRuntime(): PluginRuntime { }, channel: { text: { + chunkByNewline, chunkMarkdownText, chunkText, + chunkTextWithMode, + resolveChunkMode, resolveTextChunkLimit, hasControlCommand, resolveMarkdownTableMode, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 115cb447e..40d936762 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -35,8 +35,11 @@ type ResolveInboundDebounceMs = type ResolveCommandAuthorizedFromAuthorizers = typeof import("../../channels/command-gating.js").resolveCommandAuthorizedFromAuthorizers; type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit; +type ResolveChunkMode = typeof import("../../auto-reply/chunk.js").resolveChunkMode; type ChunkMarkdownText = typeof import("../../auto-reply/chunk.js").chunkMarkdownText; type ChunkText = typeof import("../../auto-reply/chunk.js").chunkText; +type ChunkTextWithMode = typeof import("../../auto-reply/chunk.js").chunkTextWithMode; +type ChunkByNewline = typeof import("../../auto-reply/chunk.js").chunkByNewline; type ResolveMarkdownTableMode = typeof import("../../config/markdown-tables.js").resolveMarkdownTableMode; type ConvertMarkdownTables = typeof import("../../markdown/tables.js").convertMarkdownTables; @@ -173,8 +176,11 @@ export type PluginRuntime = { }; channel: { text: { + chunkByNewline: ChunkByNewline; chunkMarkdownText: ChunkMarkdownText; chunkText: ChunkText; + chunkTextWithMode: ChunkTextWithMode; + resolveChunkMode: ResolveChunkMode; resolveTextChunkLimit: ResolveTextChunkLimit; hasControlCommand: HasControlCommand; resolveMarkdownTableMode: ResolveMarkdownTableMode;