diff --git a/CHANGELOG.md b/CHANGELOG.md index e88e5f1f2..55ae725a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ ### Fixes - Chat UI: keep the chat scrolled to the latest message after switching sessions. - Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose. +- Messages: make outbound text chunk limits configurable (defaults remain 4000/Discord 2000). - CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access. - Control UI: accept a `?token=` URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured. - Agent prompt: remove hardcoded user name in system prompt example. diff --git a/docs/configuration.md b/docs/configuration.md index 2c1506838..4d7db7238 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -282,7 +282,17 @@ Controls inbound/outbound prefixes and timestamps. messages: { messagePrefix: "[clawdis]", responsePrefix: "🦞", - timestampPrefix: "Europe/London" + timestampPrefix: "Europe/London", + // outbound chunk size (chars); defaults vary by surface (e.g. 4000, Discord 2000) + textChunkLimit: 4000, + // optional per-surface overrides + textChunkLimitBySurface: { + whatsapp: 4000, + telegram: 4000, + signal: 4000, + imessage: 4000, + discord: 2000 + } } } ``` diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index 065edc672..d0b336112 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { chunkText } from "./chunk.js"; +import { chunkText, resolveTextChunkLimit } from "./chunk.js"; describe("chunkText", () => { it("keeps multi-line text in one chunk when under limit", () => { @@ -45,3 +45,30 @@ describe("chunkText", () => { expect(chunks).toEqual(["Supercalif", "ragilistic", "expialidoc", "ious"]); }); }); + +describe("resolveTextChunkLimit", () => { + it("uses per-surface defaults", () => { + expect(resolveTextChunkLimit(undefined, "whatsapp")).toBe(4000); + expect(resolveTextChunkLimit(undefined, "telegram")).toBe(4000); + expect(resolveTextChunkLimit(undefined, "signal")).toBe(4000); + expect(resolveTextChunkLimit(undefined, "imessage")).toBe(4000); + expect(resolveTextChunkLimit(undefined, "discord")).toBe(2000); + }); + + it("supports a global override", () => { + const cfg = { messages: { textChunkLimit: 1234 } }; + expect(resolveTextChunkLimit(cfg, "whatsapp")).toBe(1234); + expect(resolveTextChunkLimit(cfg, "discord")).toBe(1234); + }); + + it("prefers per-surface overrides over global", () => { + const cfg = { + messages: { + textChunkLimit: 1234, + textChunkLimitBySurface: { discord: 111 }, + }, + }; + expect(resolveTextChunkLimit(cfg, "discord")).toBe(111); + expect(resolveTextChunkLimit(cfg, "telegram")).toBe(1234); + }); +}); diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index a9470db87..caac3feae 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -2,6 +2,43 @@ // unintentionally breaking on newlines. Using [\s\S] keeps newlines inside // the chunk so messages are only split when they truly exceed the limit. +import type { ClawdisConfig } from "../config/config.js"; + +export type TextChunkSurface = + | "whatsapp" + | "telegram" + | "discord" + | "signal" + | "imessage" + | "webchat"; + +const DEFAULT_CHUNK_LIMIT_BY_SURFACE: Record = { + whatsapp: 4000, + telegram: 4000, + discord: 2000, + signal: 4000, + imessage: 4000, + webchat: 4000, +}; + +export function resolveTextChunkLimit( + cfg: Pick | undefined, + surface?: TextChunkSurface, +): number { + const surfaceOverride = surface + ? cfg?.messages?.textChunkLimitBySurface?.[surface] + : undefined; + if (typeof surfaceOverride === "number" && surfaceOverride > 0) { + return surfaceOverride; + } + const globalOverride = cfg?.messages?.textChunkLimit; + if (typeof globalOverride === "number" && globalOverride > 0) { + return globalOverride; + } + if (surface) return DEFAULT_CHUNK_LIMIT_BY_SURFACE[surface]; + return 4000; +} + export function chunkText(text: string, limit: number): string[] { if (!text) return []; if (limit <= 0) return [text]; diff --git a/src/commands/agent.ts b/src/commands/agent.ts index d62398f27..1518f70a1 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -17,7 +17,7 @@ import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, } from "../agents/workspace.js"; -import { chunkText } from "../auto-reply/chunk.js"; +import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import type { MsgContext } from "../auto-reply/templating.js"; import { normalizeThinkLevel, @@ -524,6 +524,15 @@ export async function agentCommand( return; } + const deliveryTextLimit = + deliveryProvider === "whatsapp" || + deliveryProvider === "telegram" || + deliveryProvider === "discord" || + deliveryProvider === "signal" || + deliveryProvider === "imessage" + ? resolveTextChunkLimit(cfg, deliveryProvider) + : resolveTextChunkLimit(cfg, "whatsapp"); + for (const payload of payloads) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); @@ -564,7 +573,7 @@ export async function agentCommand( if (deliveryProvider === "telegram" && telegramTarget) { try { if (media.length === 0) { - for (const chunk of chunkText(text, 4000)) { + for (const chunk of chunkText(text, deliveryTextLimit)) { await deps.sendMessageTelegram(telegramTarget, chunk, { verbose: false, token: telegramToken || undefined, @@ -645,7 +654,7 @@ export async function agentCommand( if (deliveryProvider === "imessage" && imessageTarget) { try { if (media.length === 0) { - for (const chunk of chunkText(text, 4000)) { + for (const chunk of chunkText(text, deliveryTextLimit)) { await deps.sendMessageIMessage(imessageTarget, chunk, { maxBytes: cfg.imessage?.mediaMaxMb ? cfg.imessage.mediaMaxMb * 1024 * 1024 diff --git a/src/config/config.ts b/src/config/config.ts index 1a1dc31b1..1a9b1eca2 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -314,6 +314,15 @@ export type MessagesConfig = { messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdis]" if no allowFrom, else "") responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞") timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC) + /** Outbound text chunk size (chars). Default varies by provider (e.g. 4000, Discord 2000). */ + textChunkLimit?: number; + /** Optional per-surface chunk overrides. */ + textChunkLimitBySurface?: Partial< + Record< + "whatsapp" | "telegram" | "discord" | "signal" | "imessage" | "webchat", + number + > + >; }; export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback"; @@ -708,6 +717,17 @@ const MessagesSchema = z messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), timestampPrefix: z.union([z.boolean(), z.string()]).optional(), + textChunkLimit: z.number().int().positive().optional(), + textChunkLimitBySurface: z + .object({ + whatsapp: z.number().int().positive().optional(), + telegram: z.number().int().positive().optional(), + discord: z.number().int().positive().optional(), + signal: z.number().int().positive().optional(), + imessage: z.number().int().positive().optional(), + webchat: z.number().int().positive().optional(), + }) + .optional(), }) .optional(); diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 2ed808e91..e972837d3 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -12,7 +12,7 @@ import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, } from "../agents/workspace.js"; -import { chunkText } from "../auto-reply/chunk.js"; +import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { normalizeThinkLevel } from "../auto-reply/thinking.js"; import type { CliDeps } from "../cli/deps.js"; import type { ClawdisConfig } from "../config/config.js"; @@ -357,12 +357,13 @@ export async function runCronIsolatedAgentTurn(params: { }; } const chatId = resolvedDelivery.to; + const textLimit = resolveTextChunkLimit(params.cfg, "telegram"); try { for (const payload of payloads) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); if (mediaList.length === 0) { - for (const chunk of chunkText(payload.text ?? "", 4000)) { + for (const chunk of chunkText(payload.text ?? "", textLimit)) { await params.deps.sendMessageTelegram(chatId, chunk, { verbose: false, token: telegramToken || undefined, @@ -444,12 +445,13 @@ export async function runCronIsolatedAgentTurn(params: { }; } const to = resolvedDelivery.to; + const textLimit = resolveTextChunkLimit(params.cfg, "signal"); try { for (const payload of payloads) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); if (mediaList.length === 0) { - for (const chunk of chunkText(payload.text ?? "", 4000)) { + for (const chunk of chunkText(payload.text ?? "", textLimit)) { await params.deps.sendMessageSignal(to, chunk); } } else { @@ -482,12 +484,13 @@ export async function runCronIsolatedAgentTurn(params: { }; } const to = resolvedDelivery.to; + const textLimit = resolveTextChunkLimit(params.cfg, "imessage"); try { for (const payload of payloads) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); if (mediaList.length === 0) { - for (const chunk of chunkText(payload.text ?? "", 4000)) { + for (const chunk of chunkText(payload.text ?? "", textLimit)) { await params.deps.sendMessageIMessage(to, chunk); } } else { diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index 96e4c8315..281c62830 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -1,4 +1,4 @@ -import { chunkText } from "../auto-reply/chunk.js"; +import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; @@ -111,15 +111,16 @@ async function deliverReplies(params: { client: Awaited>; runtime: RuntimeEnv; maxBytes: number; + textLimit: number; }) { - const { replies, target, client, runtime, maxBytes } = params; + const { replies, target, client, runtime, maxBytes, textLimit } = params; for (const payload of replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const text = payload.text ?? ""; if (!text && mediaList.length === 0) continue; if (mediaList.length === 0) { - for (const chunk of chunkText(text, 4000)) { + for (const chunk of chunkText(text, textLimit)) { await sendMessageIMessage(target, chunk, { maxBytes, client }); } } else { @@ -143,6 +144,7 @@ export async function monitorIMessageProvider( ): Promise { const runtime = resolveRuntime(opts); const cfg = loadConfig(); + const textLimit = resolveTextChunkLimit(cfg, "imessage"); const allowFrom = resolveAllowFrom(opts); const mentionRegexes = resolveMentionRegexes(cfg); const includeAttachments = @@ -274,6 +276,7 @@ export async function monitorIMessageProvider( client, runtime, maxBytes: mediaMaxBytes, + textLimit, }); }) .catch((err) => { @@ -302,6 +305,7 @@ export async function monitorIMessageProvider( client, runtime, maxBytes: mediaMaxBytes, + textLimit, }); }; diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 822f537ee..d3e1ce383 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -1,4 +1,4 @@ -import { chunkText } from "../auto-reply/chunk.js"; +import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { HEARTBEAT_PROMPT, stripHeartbeatToken, @@ -292,6 +292,7 @@ async function deliverHeartbeatReply(params: { to: string; text: string; mediaUrls: string[]; + textLimit: number; deps: Required< Pick< HeartbeatDeps, @@ -303,10 +304,10 @@ async function deliverHeartbeatReply(params: { > >; }) { - const { channel, to, text, mediaUrls, deps } = params; + const { channel, to, text, mediaUrls, deps, textLimit } = params; if (channel === "whatsapp") { if (mediaUrls.length === 0) { - for (const chunk of chunkText(text, 4000)) { + for (const chunk of chunkText(text, textLimit)) { await deps.sendWhatsApp(to, chunk, { verbose: false }); } return; @@ -322,7 +323,7 @@ async function deliverHeartbeatReply(params: { if (channel === "signal") { if (mediaUrls.length === 0) { - for (const chunk of chunkText(text, 4000)) { + for (const chunk of chunkText(text, textLimit)) { await deps.sendSignal(to, chunk); } return; @@ -338,7 +339,7 @@ async function deliverHeartbeatReply(params: { if (channel === "imessage") { if (mediaUrls.length === 0) { - for (const chunk of chunkText(text, 4000)) { + for (const chunk of chunkText(text, textLimit)) { await deps.sendIMessage(to, chunk); } return; @@ -354,7 +355,7 @@ async function deliverHeartbeatReply(params: { if (channel === "telegram") { if (mediaUrls.length === 0) { - for (const chunk of chunkText(text, 4000)) { + for (const chunk of chunkText(text, textLimit)) { await deps.sendTelegram(to, chunk, { verbose: false }); } return; @@ -500,11 +501,13 @@ export async function runHeartbeatOnce(opts: { sendSignal: opts.deps?.sendSignal ?? sendMessageSignal, sendIMessage: opts.deps?.sendIMessage ?? sendMessageIMessage, }; + const textLimit = resolveTextChunkLimit(cfg, delivery.channel); await deliverHeartbeatReply({ channel: delivery.channel, to: delivery.to, text: normalized.text, mediaUrls, + textLimit, deps, }); diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 1f6d443ae..d7917d49b 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -1,4 +1,4 @@ -import { chunkText } from "../auto-reply/chunk.js"; +import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; @@ -178,15 +178,17 @@ async function deliverReplies(params: { account?: string; runtime: RuntimeEnv; maxBytes: number; + textLimit: number; }) { - const { replies, target, baseUrl, account, runtime, maxBytes } = params; + const { replies, target, baseUrl, account, runtime, maxBytes, textLimit } = + params; for (const payload of replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const text = payload.text ?? ""; if (!text && mediaList.length === 0) continue; if (mediaList.length === 0) { - for (const chunk of chunkText(text, 4000)) { + for (const chunk of chunkText(text, textLimit)) { await sendMessageSignal(target, chunk, { baseUrl, account, @@ -215,6 +217,7 @@ export async function monitorSignalProvider( ): Promise { const runtime = resolveRuntime(opts); const cfg = loadConfig(); + const textLimit = resolveTextChunkLimit(cfg, "signal"); const baseUrl = resolveBaseUrl(opts); const account = resolveAccount(opts); const allowFrom = resolveAllowFrom(opts); @@ -391,6 +394,7 @@ export async function monitorSignalProvider( account, runtime, maxBytes: mediaMaxBytes, + textLimit, }); }) .catch((err) => { @@ -420,6 +424,7 @@ export async function monitorSignalProvider( account, runtime, maxBytes: mediaMaxBytes, + textLimit, }); }; diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 7d88334f9..c5fbb6bc8 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -5,7 +5,7 @@ import { apiThrottler } from "@grammyjs/transformer-throttler"; import type { ApiClientOptions, Message } from "grammy"; import { Bot, InputFile, webhookCallback } from "grammy"; -import { chunkText } from "../auto-reply/chunk.js"; +import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; @@ -60,6 +60,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { bot.api.config.use(apiThrottler()); const cfg = loadConfig(); + const textLimit = resolveTextChunkLimit(cfg, "telegram"); const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom; const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off"; const mediaMaxBytes = @@ -245,6 +246,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { runtime, bot, replyToMode, + textLimit, }); } catch (err) { runtime.error?.(danger(`handler failed: ${String(err)}`)); @@ -268,8 +270,9 @@ async function deliverReplies(params: { runtime: RuntimeEnv; bot: Bot; replyToMode: ReplyToMode; + textLimit: number; }) { - const { replies, chatId, runtime, bot, replyToMode } = params; + const { replies, chatId, runtime, bot, replyToMode, textLimit } = params; let hasReplied = false; for (const reply of replies) { if (!reply?.text && !reply?.mediaUrl && !(reply?.mediaUrls?.length ?? 0)) { @@ -286,7 +289,7 @@ async function deliverReplies(params: { ? [reply.mediaUrl] : []; if (mediaList.length === 0) { - for (const chunk of chunkText(reply.text || "", 4000)) { + for (const chunk of chunkText(reply.text || "", textLimit)) { await sendTelegramText(bot, chatId, chunk, runtime, { replyToMessageId: replyToId && (replyToMode === "all" || !hasReplied) diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 67be507a6..3d4879a99 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -1,4 +1,4 @@ -import { chunkText } from "../auto-reply/chunk.js"; +import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { normalizeGroupActivation, @@ -41,7 +41,6 @@ import { } from "./reconnect.js"; import { formatError, getWebAuthAgeMs, readWebSelfId } from "./session.js"; -const WEB_TEXT_LIMIT = 4000; const DEFAULT_GROUP_HISTORY_LIMIT = 50; const whatsappLog = createSubsystemLogger("gateway/providers/whatsapp"); const whatsappInboundLog = whatsappLog.child("inbound"); @@ -502,6 +501,7 @@ async function deliverWebReply(params: { replyResult: ReplyPayload; msg: WebInboundMsg; maxMediaBytes: number; + textLimit: number; replyLogger: ReturnType; connectionId?: string; skipLog?: boolean; @@ -510,12 +510,13 @@ async function deliverWebReply(params: { replyResult, msg, maxMediaBytes, + textLimit, replyLogger, connectionId, skipLog, } = params; const replyStarted = Date.now(); - const textChunks = chunkText(replyResult.text || "", WEB_TEXT_LIMIT); + const textChunks = chunkText(replyResult.text || "", textLimit); const mediaList = replyResult.mediaUrls?.length ? replyResult.mediaUrls : replyResult.mediaUrl @@ -1050,6 +1051,7 @@ export async function monitorWebProvider( } const responsePrefix = cfg.messages?.responsePrefix; + const textLimit = resolveTextChunkLimit(cfg, "whatsapp"); let didLogHeartbeatStrip = false; let didSendReply = false; let toolSendChain: Promise = Promise.resolve(); @@ -1091,6 +1093,7 @@ export async function monitorWebProvider( replyResult: toolPayload, msg, maxMediaBytes, + textLimit, replyLogger, connectionId, skipLog: true, @@ -1134,6 +1137,7 @@ export async function monitorWebProvider( replyResult: blockPayload, msg, maxMediaBytes, + textLimit, replyLogger, connectionId, skipLog: true, @@ -1238,6 +1242,7 @@ export async function monitorWebProvider( replyResult: replyPayload, msg, maxMediaBytes, + textLimit, replyLogger, connectionId, });