Compare commits
5 Commits
main
...
feat/newli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
354d77b33f | ||
|
|
b9edeae961 | ||
|
|
824154deba | ||
|
|
50cec30c76 | ||
|
|
4e4ff506da |
@ -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.
|
||||
|
||||
@ -196,6 +196,7 @@ Provider options:
|
||||
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
|
||||
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`).
|
||||
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
|
||||
- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline and sends each line immediately during streaming.
|
||||
- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8).
|
||||
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
|
||||
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
|
||||
|
||||
@ -47,7 +47,8 @@ function mergeBlueBubblesAccountConfig(
|
||||
};
|
||||
const { accounts: _ignored, ...rest } = base;
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...rest, ...account };
|
||||
const chunkMode = account.chunkMode ?? rest.chunkMode ?? "length";
|
||||
return { ...rest, ...account, chunkMode };
|
||||
}
|
||||
|
||||
export function resolveBlueBubblesAccount(params: {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -1851,16 +1851,21 @@ async function processMessage(
|
||||
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
||||
? account.config.textChunkLimit
|
||||
: DEFAULT_TEXT_LIMIT;
|
||||
const chunkMode = account.config.chunkMode ?? "length";
|
||||
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 +1874,17 @@ 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
|
||||
// Small delay allows the Apple API to finish clearing the typing state from message send
|
||||
if (chunkMode === "newline" && i < chunks.length - 1 && chatGuidForActions) {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
sendBlueBubblesTyping(chatGuidForActions, true, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
}).catch(() => {
|
||||
// Ignore typing errors
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onReplyStart: async () => {
|
||||
|
||||
@ -38,6 +38,8 @@ export type BlueBubblesAccountConfig = {
|
||||
dms?: Record<string, unknown>;
|
||||
/** 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<string, unknown>;
|
||||
|
||||
@ -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,101 @@ 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("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", "\n\nLine two", "\nLine 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("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);
|
||||
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");
|
||||
expect(resolveChunkMode(undefined, "bluebubbles")).toBe("length");
|
||||
});
|
||||
|
||||
it("returns length for internal channel", () => {
|
||||
const cfg = { channels: { bluebubbles: { chunkMode: "newline" as const } } };
|
||||
expect(resolveChunkMode(cfg, "__internal__")).toBe("length");
|
||||
});
|
||||
|
||||
it("supports provider-level overrides for bluebubbles", () => {
|
||||
const cfg = { channels: { bluebubbles: { chunkMode: "newline" as const } } };
|
||||
expect(resolveChunkMode(cfg, "bluebubbles")).toBe("newline");
|
||||
expect(resolveChunkMode(cfg, "discord")).toBe("length");
|
||||
});
|
||||
|
||||
it("supports account-level overrides for bluebubbles", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
chunkMode: "length" as const,
|
||||
accounts: {
|
||||
primary: { chunkMode: "newline" as const },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(resolveChunkMode(cfg, "bluebubbles", "primary")).toBe("newline");
|
||||
expect(resolveChunkMode(cfg, "bluebubbles", "other")).toBe("length");
|
||||
});
|
||||
|
||||
it("ignores chunkMode for non-bluebubbles providers", () => {
|
||||
const cfg = { channels: { ["telegram" as string]: { chunkMode: "newline" as const } } };
|
||||
expect(resolveChunkMode(cfg, "telegram")).toBe("length");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string, { textChunkLimit?: number }>;
|
||||
chunkMode?: ChunkMode;
|
||||
accounts?: Record<string, { textChunkLimit?: number; chunkMode?: ChunkMode }>;
|
||||
};
|
||||
|
||||
function resolveChunkLimitForProvider(
|
||||
@ -63,6 +72,94 @@ 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;
|
||||
// Chunk mode is only supported for BlueBubbles.
|
||||
if (provider !== "bluebubbles") return DEFAULT_CHUNK_MODE;
|
||||
const channelsConfig = cfg?.channels as Record<string, unknown> | undefined;
|
||||
const providerConfig = (channelsConfig?.[provider] ??
|
||||
(cfg as Record<string, unknown> | 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 [];
|
||||
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) {
|
||||
pendingBlankLines += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
@ -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;
|
||||
|
||||
// BlueBubbles-only: if chunkMode is "newline", 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);
|
||||
|
||||
// BlueBubbles-only: when chunkMode is "newline", disable coalescing 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
|
||||
|
||||
@ -633,6 +633,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(),
|
||||
|
||||
@ -212,7 +212,8 @@ export async function deliverOutboundPayloads(params: {
|
||||
results.push(await handler.sendText(text));
|
||||
return;
|
||||
}
|
||||
for (const chunk of handler.chunker(text, textLimit)) {
|
||||
const chunks = handler.chunker(text, textLimit);
|
||||
for (const chunk of chunks) {
|
||||
throwIfAborted(abortSignal);
|
||||
results.push(await handler.sendText(chunk));
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user