Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -264,7 +265,7 @@ export const meshtasticPlugin: ChannelPlugin<ResolvedMeshtasticAccount, Meshtast
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getMeshtasticRuntime().channel.text.chunkText(text, limit),
chunker: (text, limit) => chunkText(text, limit),
chunkerMode: "text",
textChunkLimit: 200,
sendText: async ({ to, text, accountId }) => {
Expand Down
53 changes: 53 additions & 0 deletions src/chunk.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// 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;
}
23 changes: 1 addition & 22 deletions src/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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). " +
Expand All @@ -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;
Expand Down