From 45e49707dbc48530de07f58d772f8c1df844dcf6 Mon Sep 17 00:00:00 2001 From: 1Croydan1 <1Croydan1@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:27:49 +0300 Subject: [PATCH 1/2] fix: keep URLs intact when chunking LoRa replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reply chunker broke at a fixed offset whenever no whitespace was found near the limit (`breakAt <= limit * 0.4` → `breakAt = limit`), which sliced long URLs in half — each half arriving as a separate, unusable link. Replace it with a word/URL-aware chunker in a shared `chunk.ts`, used by both the MQTT and serial reply paths: pack whitespace-separated tokens up to the soft limit without ever splitting a token, and keep an over-limit token (typically a URL) whole up to the LoRa frame limit — hard-splitting only as a last resort when it cannot fit a single frame at all. Verified live over a real Meshtastic gateway: a reply containing a 92-char URL now arrives as ["Документация по модулю MQTT:", "", "читай ..."] with the link intact, instead of being cut mid-URL. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/channel.ts | 3 ++- src/chunk.ts | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/inbound.ts | 23 +--------------------- 3 files changed, 56 insertions(+), 23 deletions(-) create mode 100644 src/chunk.ts 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..6dd00da --- /dev/null +++ b/src/chunk.ts @@ -0,0 +1,53 @@ +// LoRa text payload is ~230 bytes per frame. The soft limit (textChunkLimit) +// keeps replies short; the hard limit is the most a single frame can carry. +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 `limit`. A single token longer + * than `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 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(); + if (trimmed.length <= limit) { + return trimmed ? [trimmed] : []; + } + + const hardMax = Math.max(limit, MESHTASTIC_HARD_LIMIT); + + // 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 <= limit) { + 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 <= hardMax) { + chunks.push(entry); + continue; + } + for (let i = 0; i < entry.length; i += hardMax) { + chunks.push(entry.slice(i, i + hardMax)); + } + } + 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; From 8f4810c9a70bffeffe80b97b89d3a9a361a30405 Mon Sep 17 00:00:00 2001 From: 1Croydan1 <1Croydan1@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:44:11 +0300 Subject: [PATCH 2/2] fix(chunk): clamp soft limit to the LoRa frame size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review (PR #20): `Math.max(limit, MESHTASTIC_HARD_LIMIT)` let an over-large textChunkLimit (>230) produce chunks bigger than one frame. Clamp the soft packing limit to the frame instead, and keep the hard keep-whole ceiling at MESHTASTIC_HARD_LIMIT, so no chunk ever exceeds a frame while long URLs are still kept whole up to the frame size. The naive `Math.min(limit, HARD)` suggested inline would instead re-split URLs whenever limit < 230 (the common LoRa case, e.g. limit=90) — defeating the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/chunk.ts | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/chunk.ts b/src/chunk.ts index 6dd00da..d2614be 100644 --- a/src/chunk.ts +++ b/src/chunk.ts @@ -1,26 +1,31 @@ // LoRa text payload is ~230 bytes per frame. The soft limit (textChunkLimit) -// keeps replies short; the hard limit is the most a single frame can carry. +// 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 `limit`. A single token longer - * than `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 previous implementation broke at a fixed offset - * when no nearby space was found, which sliced long URLs in half and produced - * two unusable links. + * 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(); - if (trimmed.length <= limit) { + // 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] : []; } - const hardMax = Math.max(limit, MESHTASTIC_HARD_LIMIT); - // Greedily pack whitespace-separated tokens, never splitting a token. const packed: string[] = []; let cur = ""; @@ -28,7 +33,7 @@ export function chunkText(text: string, limit: number): string[] { if (!word) continue; if (cur === "") { cur = word; - } else if (cur.length + 1 + word.length <= limit) { + } else if (cur.length + 1 + word.length <= softLimit) { cur += " " + word; } else { packed.push(cur); @@ -41,12 +46,12 @@ export function chunkText(text: string, limit: number): string[] { // (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 <= hardMax) { + if (entry.length <= MESHTASTIC_HARD_LIMIT) { chunks.push(entry); continue; } - for (let i = 0; i < entry.length; i += hardMax) { - chunks.push(entry.slice(i, i + hardMax)); + for (let i = 0; i < entry.length; i += MESHTASTIC_HARD_LIMIT) { + chunks.push(entry.slice(i, i + MESHTASTIC_HARD_LIMIT)); } } return chunks;