diff --git a/src/channel.ts b/src/channel.ts index f90cb82..7779535 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -17,6 +17,7 @@ import { resolveMeshtasticAccount, type ResolvedMeshtasticAccount, } from "./accounts.js"; +import { chunkText } from "./chunk.js"; import { MeshtasticConfigSchema } from "./config-schema.js"; import { monitorMeshtasticProvider } from "./monitor.js"; import { @@ -264,7 +265,7 @@ export const meshtasticPlugin: ChannelPlugin getMeshtasticRuntime().channel.text.chunkText(text, limit), + chunker: (text, limit) => chunkText(text, limit), chunkerMode: "text", textChunkLimit: 200, sendText: async ({ to, text, accountId }) => { diff --git a/src/chunk.ts b/src/chunk.ts new file mode 100644 index 0000000..d2614be --- /dev/null +++ b/src/chunk.ts @@ -0,0 +1,58 @@ +// LoRa text payload is ~230 bytes per frame. The soft limit (textChunkLimit) +// keeps replies short; MESHTASTIC_HARD_LIMIT is the most a single frame can +// carry and is enforced as an absolute ceiling. +export const MESHTASTIC_CHUNK_LIMIT = 200; +export const MESHTASTIC_HARD_LIMIT = 230; + +/** + * Split a reply into LoRa-sized chunks without breaking words or URLs. + * + * Reflows on whitespace, packing tokens up to the soft limit. A single token + * longer than the soft limit (typically a URL) is kept whole up to the hard + * frame limit so links stay valid — it's only hard-split as a last resort when + * it cannot fit a single frame at all. The soft limit is clamped to the hard + * frame limit, so an over-large `textChunkLimit` can never produce a chunk that + * exceeds what one frame can carry. + * + * The previous implementation broke at a fixed offset when no nearby space was + * found, which sliced long URLs in half and produced two unusable links. + */ +export function chunkText(text: string, limit: number): string[] { + const trimmed = text.trim(); + // Clamp the soft limit to the physical frame size: callers may request a + // smaller (stricter) limit, but never a larger one than a frame can carry. + const softLimit = Math.max(1, Math.min(limit, MESHTASTIC_HARD_LIMIT)); + if (trimmed.length <= softLimit) { + return trimmed ? [trimmed] : []; + } + + // Greedily pack whitespace-separated tokens, never splitting a token. + const packed: string[] = []; + let cur = ""; + for (const word of trimmed.split(/\s+/)) { + if (!word) continue; + if (cur === "") { + cur = word; + } else if (cur.length + 1 + word.length <= softLimit) { + cur += " " + word; + } else { + packed.push(cur); + cur = word; + } + } + if (cur) packed.push(cur); + + // A packed entry may still be a single token longer than the soft limit + // (e.g. a URL). Keep it whole if it fits one frame; otherwise hard-split. + const chunks: string[] = []; + for (const entry of packed) { + if (entry.length <= MESHTASTIC_HARD_LIMIT) { + chunks.push(entry); + continue; + } + for (let i = 0; i < entry.length; i += MESHTASTIC_HARD_LIMIT) { + chunks.push(entry.slice(i, i + MESHTASTIC_HARD_LIMIT)); + } + } + return chunks; +} diff --git a/src/inbound.ts b/src/inbound.ts index c885ebe..3f704a1 100644 --- a/src/inbound.ts +++ b/src/inbound.ts @@ -14,6 +14,7 @@ import { } from "openclaw/plugin-sdk/irc"; import { createReplyPrefixOptions } from "openclaw/plugin-sdk/matrix"; import type { ResolvedMeshtasticAccount } from "./accounts.js"; +import { chunkText, MESHTASTIC_CHUNK_LIMIT } from "./chunk.js"; import { normalizeMeshtasticAllowlist, normalizeMeshtasticNodeId, @@ -47,10 +48,6 @@ function resolveMeshtasticEffectiveAllowlists(params: { return { effectiveAllowFrom, effectiveGroupAllowFrom }; } -// LoRa payload limit is ~230 bytes. Split longer replies into chunks -// so the firmware doesn't silently truncate them. -const MESHTASTIC_CHUNK_LIMIT = 200; - /** Channel-level system prompt hint for LoRa-constrained responses. */ const LORA_SYSTEM_HINT = "You are responding over a LoRa mesh radio (Meshtastic). " + @@ -59,24 +56,6 @@ const LORA_SYSTEM_HINT = "Use short sentences. Omit filler words. Prioritize the most important information first."; -function chunkText(text: string, limit: number): string[] { - if (text.length <= limit) return [text]; - const chunks: string[] = []; - let remaining = text; - while (remaining.length > 0) { - if (remaining.length <= limit) { - chunks.push(remaining); - break; - } - // Try to break at a space near the limit. - let breakAt = remaining.lastIndexOf(" ", limit); - if (breakAt <= limit * 0.4) breakAt = limit; // no good break point - chunks.push(remaining.slice(0, breakAt).trimEnd()); - remaining = remaining.slice(breakAt).trimStart(); - } - return chunks; -} - async function deliverMeshtasticReply(params: { payload: OutboundReplyPayload; target: string;