Skip to content

Commit 4cbbb86

Browse files
authored
refactor(zalouser): share offset-preserving text chunk ranges (#105842)
* refactor(zalouser): share text chunk ranges * chore(plugin-sdk): refresh combined API baseline
1 parent be7241b commit 4cbbb86

10 files changed

Lines changed: 160 additions & 94 deletions

File tree

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
bc84c5da613ba34f84e9265ed0580ae99d8793a6033519dee33e11863daaff62 plugin-sdk-api-baseline.json
2-
a25e6c9ae393270029eb39de5e00a45f5a3ba13e526ff7532668494f3772159b plugin-sdk-api-baseline.jsonl
1+
21f63180d1606d450af8d2509c2e12ae7bcb0e92b77429ee35b5d350d4f9a427 plugin-sdk-api-baseline.json
2+
7ea611a65949cbd82c54a63f3fc1818d88bf6b6515fcb53ef59a911b2ca4a822 plugin-sdk-api-baseline.jsonl

docs/plugins/sdk-migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,7 @@ SDK.
500500
| `plugin-sdk/media-generation-runtime` | Shared media-generation helpers | Shared failover helpers, candidate selection, and missing-model messaging for image/video/music generation |
501501
| `plugin-sdk/media-understanding` | Media-understanding helpers | Media understanding provider types plus provider-facing image/audio helper exports |
502502
| `plugin-sdk/text-runtime` | Deprecated broad text compatibility export | Use `string-coerce-runtime`, `text-chunking`, `text-utility-runtime`, and `logging-core` |
503-
| `plugin-sdk/text-chunking` | Text chunking helpers | Outbound text chunking helper |
503+
| `plugin-sdk/text-chunking` | Text chunking helpers | Outbound text and offset-preserving range chunking helpers |
504504
| `plugin-sdk/speech` | Speech helpers | Speech provider types plus provider-facing directive, registry, validation helpers, and OpenAI-compatible TTS builder |
505505
| `plugin-sdk/speech-core` | Shared speech core | Speech provider types, registry, directives, normalization |
506506
| `plugin-sdk/realtime-transcription` | Realtime transcription helpers | Provider types, registry helpers, and shared WebSocket session helper |

docs/plugins/sdk-subpaths.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ usage endpoint failed or returned no usable usage data.
352352
| `plugin-sdk/media-store` | Narrow media store helpers such as `saveMediaBuffer` and `saveMediaStream` |
353353
| `plugin-sdk/media-generation-runtime` | Shared media-generation failover helpers, candidate selection, and missing-model messaging |
354354
| `plugin-sdk/media-understanding` | Media understanding provider types plus provider-facing image/audio/structured-extraction helper exports |
355-
| `plugin-sdk/text-chunking` | Outbound text and markdown chunking/render helpers, markdown table conversion, directive-tag stripping, and safe-text utilities |
355+
| `plugin-sdk/text-chunking` | Outbound text and offset-preserving range chunking, markdown chunking/render helpers, markdown table conversion, directive-tag stripping, and safe-text utilities |
356356
| `plugin-sdk/speech` | Speech provider types plus provider-facing directive, registry, validation, OpenAI-compatible TTS builder, and speech helper exports |
357357
| `plugin-sdk/speech-core` | Shared speech provider types, registry, directive, normalization, and speech helper exports |
358358
| `plugin-sdk/realtime-transcription` | Realtime transcription provider types, registry helpers, and shared WebSocket session helper |

extensions/zalouser/src/send.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,42 @@ describe("zalouser send helpers", () => {
296296
expectResultFields(result, { ok: true, messageId: "mid-2d-4" });
297297
});
298298

299+
it.each([
300+
{
301+
name: "fenced code with hard limits",
302+
text: "```ts\nconst alpha = 1;\nconst beta = 2;\n```",
303+
textChunkLimit: 16,
304+
textChunkMode: "length" as const,
305+
expected: ["const alpha = 1;", "\nconst beta = 2;"],
306+
},
307+
{
308+
name: "tables with newline-preferred limits",
309+
text: "| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |",
310+
textChunkLimit: 24,
311+
textChunkMode: "newline" as const,
312+
expected: ["| A | B |\n| --- | --- |\n", "| 1 | 2 |\n| 3 | 4 |"],
313+
},
314+
{
315+
name: "repeated whitespace around paragraph boundaries",
316+
text: "alpha beta\n\ngamma delta",
317+
textChunkLimit: 12,
318+
textChunkMode: "newline" as const,
319+
expected: ["alpha beta\n", "\ngamma delta"],
320+
},
321+
])("preserves exact rendered chunks for $name", async (fixture) => {
322+
mockSendText.mockResolvedValue(sendResult("mid-parity", "thread-parity"));
323+
324+
await sendMessageZalouser("thread-parity", fixture.text, {
325+
textMode: "markdown",
326+
textChunkLimit: fixture.textChunkLimit,
327+
textChunkMode: fixture.textChunkMode,
328+
});
329+
330+
const renderedChunks = mockSendText.mock.calls.map((call) => call[1]);
331+
expect(renderedChunks).toEqual(fixture.expected);
332+
expect(renderedChunks.join("")).toBe(parseZalouserTextStyles(fixture.text).text);
333+
});
334+
299335
it("respects an explicit text chunk limit when splitting formatted markdown", async () => {
300336
const text = `**${"a".repeat(1501)}**`;
301337
mockSendText

extensions/zalouser/src/send.ts

Lines changed: 5 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Zalouser plugin module implements send behavior.
2+
import { chunkTextRanges } from "openclaw/plugin-sdk/text-chunking";
23
import { createZalouserSendReceipt } from "./send-receipt.js";
34
import { parseZalouserTextStyles } from "./text-styles.js";
45
import type { ZaloEventMessage, ZaloSendOptions, ZaloSendResult } from "./types.js";
@@ -19,15 +20,12 @@ type ZalouserSendOptions = ZaloSendOptions & {
1920
type ZalouserSendResult = ZaloSendResult;
2021

2122
const ZALO_TEXT_LIMIT = 2000;
22-
const DEFAULT_TEXT_CHUNK_MODE = "length";
2323

2424
type StyledTextChunk = {
2525
text: string;
2626
styles?: ZaloSendOptions["textStyles"];
2727
};
2828

29-
type TextChunkMode = NonNullable<ZaloSendOptions["textChunkMode"]>;
30-
3129
export async function sendMessageZalouser(
3230
threadId: string,
3331
text: string,
@@ -155,7 +153,10 @@ function splitStyledText(
155153
}
156154

157155
const chunks: StyledTextChunk[] = [];
158-
for (const range of splitTextRanges(text, limit, mode ?? DEFAULT_TEXT_CHUNK_MODE)) {
156+
for (const range of chunkTextRanges(text, {
157+
limit,
158+
mode: mode === "newline" ? "preferred" : "hard",
159+
})) {
159160
const { start, end } = range;
160161
chunks.push({
161162
text: text.slice(start, end),
@@ -201,86 +202,3 @@ function sliceTextStyles(
201202

202203
return chunkStyles.length > 0 ? chunkStyles : undefined;
203204
}
204-
205-
function splitTextRanges(
206-
text: string,
207-
limit: number,
208-
mode: TextChunkMode,
209-
): Array<{ start: number; end: number }> {
210-
if (mode === "newline") {
211-
return splitTextRangesByPreferredBreaks(text, limit);
212-
}
213-
214-
const ranges: Array<{ start: number; end: number }> = [];
215-
for (let start = 0; start < text.length; start += limit) {
216-
ranges.push({
217-
start,
218-
end: Math.min(text.length, start + limit),
219-
});
220-
}
221-
return ranges;
222-
}
223-
224-
function splitTextRangesByPreferredBreaks(
225-
text: string,
226-
limit: number,
227-
): Array<{ start: number; end: number }> {
228-
const ranges: Array<{ start: number; end: number }> = [];
229-
let start = 0;
230-
231-
while (start < text.length) {
232-
const maxEnd = Math.min(text.length, start + limit);
233-
let end = maxEnd;
234-
if (maxEnd < text.length) {
235-
end =
236-
findParagraphBreak(text, start, maxEnd) ??
237-
findLastBreak(text, "\n", start, maxEnd) ??
238-
findLastWhitespaceBreak(text, start, maxEnd) ??
239-
maxEnd;
240-
}
241-
242-
if (end <= start) {
243-
end = maxEnd;
244-
}
245-
246-
ranges.push({ start, end });
247-
start = end;
248-
}
249-
250-
return ranges;
251-
}
252-
253-
function findParagraphBreak(text: string, start: number, end: number): number | undefined {
254-
const slice = text.slice(start, end);
255-
const matches = slice.matchAll(/\n[\t ]*\n+/g);
256-
let lastMatch: RegExpMatchArray | undefined;
257-
for (const match of matches) {
258-
lastMatch = match;
259-
}
260-
if (!lastMatch || lastMatch.index === undefined) {
261-
return undefined;
262-
}
263-
return start + lastMatch.index + lastMatch[0].length;
264-
}
265-
266-
function findLastBreak(
267-
text: string,
268-
marker: string,
269-
start: number,
270-
end: number,
271-
): number | undefined {
272-
const index = text.lastIndexOf(marker, end - 1);
273-
if (index < start) {
274-
return undefined;
275-
}
276-
return index + marker.length;
277-
}
278-
279-
function findLastWhitespaceBreak(text: string, start: number, end: number): number | undefined {
280-
for (let index = end - 1; index > start; index -= 1) {
281-
if (/\s/.test(text.charAt(index))) {
282-
return index + 1;
283-
}
284-
}
285-
return undefined;
286-
}

packages/markdown-core/src/chunk-text.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,69 @@ function scanParenAwareBreakpoints(text: string): { lastNewline: number; lastWhi
4242
return { lastNewline, lastWhitespace };
4343
}
4444

45+
export type TextChunkRange = {
46+
start: number;
47+
end: number;
48+
};
49+
50+
export type ChunkTextRangesOptions = {
51+
limit: number;
52+
mode?: "hard" | "preferred";
53+
};
54+
55+
function findPreferredRangeEnd(text: string, start: number, end: number): number | undefined {
56+
const slice = text.slice(start, end);
57+
let paragraphEnd: number | undefined;
58+
for (const match of slice.matchAll(/\n[\t ]*\n+/g)) {
59+
if (match.index !== undefined) {
60+
paragraphEnd = start + match.index + match[0].length;
61+
}
62+
}
63+
if (paragraphEnd !== undefined) {
64+
return paragraphEnd;
65+
}
66+
67+
const newlineIndex = text.lastIndexOf("\n", end - 1);
68+
if (newlineIndex >= start) {
69+
return newlineIndex + 1;
70+
}
71+
72+
for (let index = end - 1; index > start; index -= 1) {
73+
if (/\s/.test(text.charAt(index))) {
74+
return index + 1;
75+
}
76+
}
77+
return undefined;
78+
}
79+
80+
/**
81+
* Splits text into contiguous UTF-16 ranges without dropping separator whitespace.
82+
* Preferred mode selects paragraph, newline, then whitespace boundaries.
83+
*/
84+
export function chunkTextRanges(text: string, options: ChunkTextRangesOptions): TextChunkRange[] {
85+
if (!text) {
86+
return [];
87+
}
88+
if (options.limit <= 0 || text.length <= options.limit) {
89+
return [{ start: 0, end: text.length }];
90+
}
91+
92+
const ranges: TextChunkRange[] = [];
93+
let start = 0;
94+
while (start < text.length) {
95+
const maxEnd = Math.min(text.length, start + options.limit);
96+
const preferredEnd =
97+
options.mode === "preferred" && maxEnd < text.length
98+
? findPreferredRangeEnd(text, start, maxEnd)
99+
: undefined;
100+
const candidateEnd = preferredEnd && preferredEnd > start ? preferredEnd : maxEnd;
101+
const end = avoidTrailingHighSurrogateBreak(text, start, candidateEnd);
102+
ranges.push({ start, end });
103+
start = end;
104+
}
105+
return ranges;
106+
}
107+
45108
/**
46109
* Keeps UTF-16 chunk boundaries from separating a supplementary-plane character.
47110
* A one-unit positive limit still needs to emit an entire surrogate pair.

packages/markdown-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/** Public Markdown parsing, rendering, chunking, and table-conversion utilities. */
2+
export * from "./chunk-text.js";
23
export * from "./code-spans.js";
34
export * from "./fences.js";
45
export * from "./frontmatter.js";

scripts/plugin-sdk-surface-report.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,12 +203,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
203203
),
204204
publicExports: readPluginSdkSurfaceBudgetEnv(
205205
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
206-
10641,
206+
10644,
207207
env,
208208
),
209209
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
210210
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
211-
5357,
211+
5358,
212212
env,
213213
),
214214
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(

src/plugin-sdk/text-chunking.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Tests text and Markdown chunking helpers exported by the plugin SDK.
33
*/
44
import { describe, expect, it } from "vitest";
5-
import { chunkTextForOutbound } from "./text-chunking.js";
5+
import { chunkTextForOutbound, chunkTextRanges } from "./text-chunking.js";
66

77
describe("chunkTextForOutbound", () => {
88
it.each([
@@ -28,3 +28,44 @@ describe("chunkTextForOutbound", () => {
2828
expect(chunkTextForOutbound(text, maxLen)).toEqual(expected);
2929
});
3030
});
31+
32+
describe("chunkTextRanges", () => {
33+
it("returns contiguous hard ranges without dropping whitespace", () => {
34+
const text = "alpha beta\n\ngamma delta";
35+
const ranges = chunkTextRanges(text, { limit: 12, mode: "hard" });
36+
37+
expect(ranges).toEqual([
38+
{ start: 0, end: 12 },
39+
{ start: 12, end: 24 },
40+
]);
41+
expect(ranges.map(({ start, end }) => text.slice(start, end))).toEqual([
42+
"alpha beta\n",
43+
"\ngamma delta",
44+
]);
45+
});
46+
47+
it("prefers paragraph, newline, then whitespace boundaries", () => {
48+
const text = "a\n\nb\nc xyz";
49+
const ranges = chunkTextRanges(text, { limit: 8, mode: "preferred" });
50+
51+
expect(ranges.map(({ start, end }) => text.slice(start, end))).toEqual(["a\n\n", "b\nc xyz"]);
52+
});
53+
54+
it("falls back to hard ranges and handles empty or non-positive limits", () => {
55+
expect(chunkTextRanges("abcdefgh", { limit: 3, mode: "preferred" })).toEqual([
56+
{ start: 0, end: 3 },
57+
{ start: 3, end: 6 },
58+
{ start: 6, end: 8 },
59+
]);
60+
expect(chunkTextRanges("", { limit: 3 })).toEqual([]);
61+
expect(chunkTextRanges("abc", { limit: 0 })).toEqual([{ start: 0, end: 3 }]);
62+
});
63+
64+
it.each(["hard", "preferred"] as const)("keeps surrogate pairs intact in %s mode", (mode) => {
65+
expect(chunkTextRanges("a😀b", { limit: 2, mode })).toEqual([
66+
{ start: 0, end: 1 },
67+
{ start: 1, end: 3 },
68+
{ start: 3, end: 4 },
69+
]);
70+
});
71+
});

src/plugin-sdk/text-chunking.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
// Text chunking helpers split long outbound text while preserving readable line boundaries.
22
import { chunkTextByBreakResolver } from "../shared/text-chunking.js";
33

4+
/** Offset-preserving text ranges for transports with native style metadata. */
5+
export {
6+
chunkTextRanges,
7+
type ChunkTextRangesOptions,
8+
type TextChunkRange,
9+
} from "../../packages/markdown-core/src/chunk-text.js";
10+
411
/**
512
* Splits outbound channel text into chunks no longer than the requested limit.
613
* Newline boundaries win over spaces; text without usable separators falls back

0 commit comments

Comments
 (0)