Files
openclaw/packages/markdown-core/src/chunk-text.ts
Peter Steinberger 2aa4ff0825 refactor: consolidate duplicate helper exports (#106016)
* refactor(normalization): consolidate string helpers

* refactor(normalization): consolidate agent ids

* refactor(paths): consolidate user path resolution

* refactor(gateway): preserve transcript helper facade

* fix(normalization): align helper build aliases

* fix(memory): keep helper import line neutral

* chore(plugin-sdk): align consolidated helper surface

* refactor(normalization): reuse lowercase string helper

* refactor(normalization): reuse record guard

* refactor(normalization): share surrogate boundary helper

* refactor(agents): share token estimate constant

* refactor(memory): distinguish standalone helper contracts

* refactor(normalization): share error cause formatter

* refactor(config): distinguish eligibility predicates

* refactor(plugins): share registry state symbol

* refactor(cli): reuse outbound dependency adapter

* refactor(cron): distinguish positive duration parser

* fix(gateway): keep transcript helper private

* fix(paths): preserve public helper status

* refactor(channels): reuse registry normalizer

* refactor(agents): reuse agent core runtime facade

* refactor(auth): reuse locked profile upsert

* refactor(records): distinguish trap-safe guard

* refactor(migrations): distinguish sync directory helper

* test(plugins): drop stale duration alias expectations

* fix(channels): remove stale registry import

* chore(plugin-sdk): refresh helper API baseline

* fix(memory): keep renamed helpers private

* fix(plugins): keep registry state key private

* fix(plugin-sdk): tighten wildcard surface budget

* chore(plugin-sdk): refresh rebased helper API baseline
2026-07-13 03:22:00 -07:00

149 lines
4.1 KiB
TypeScript

// Markdown Core module implements chunk text behavior.
import { avoidTrailingHighSurrogateBreak } from "@openclaw/normalization-core/utf16-slice";
export { avoidTrailingHighSurrogateBreak };
function resolveChunkEarlyReturn(text: string, limit: number): string[] | undefined {
if (!text) {
return [];
}
if (limit <= 0) {
return [text];
}
if (text.length <= limit) {
return [text];
}
return undefined;
}
function scanParenAwareBreakpoints(text: string): { lastNewline: number; lastWhitespace: number } {
let lastNewline = -1;
let lastWhitespace = -1;
let depth = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charAt(i);
// Parenthesized spans often contain rewritten links or file references;
// avoid splitting them unless the window has no safer outside break.
if (char === "(") {
depth += 1;
continue;
}
if (char === ")" && depth > 0) {
depth -= 1;
continue;
}
if (depth !== 0) {
continue;
}
if (char === "\n") {
lastNewline = i;
} else if (/\s/.test(char)) {
lastWhitespace = i;
}
}
return { lastNewline, lastWhitespace };
}
export type TextChunkRange = {
start: number;
end: number;
};
export type ChunkTextRangesOptions = {
limit: number;
mode?: "hard" | "preferred";
};
function findPreferredRangeEnd(text: string, start: number, end: number): number | undefined {
const slice = text.slice(start, end);
let paragraphEnd: number | undefined;
for (const match of slice.matchAll(/\n[\t ]*\n+/g)) {
if (match.index !== undefined) {
paragraphEnd = start + match.index + match[0].length;
}
}
if (paragraphEnd !== undefined) {
return paragraphEnd;
}
const newlineIndex = text.lastIndexOf("\n", end - 1);
if (newlineIndex >= start) {
return newlineIndex + 1;
}
for (let index = end - 1; index > start; index -= 1) {
if (/\s/.test(text.charAt(index))) {
return index + 1;
}
}
return undefined;
}
/**
* Splits text into contiguous UTF-16 ranges without dropping separator whitespace.
* Preferred mode selects paragraph, newline, then whitespace boundaries.
*/
export function chunkTextRanges(text: string, options: ChunkTextRangesOptions): TextChunkRange[] {
if (!text) {
return [];
}
if (options.limit <= 0 || text.length <= options.limit) {
return [{ start: 0, end: text.length }];
}
const ranges: TextChunkRange[] = [];
let start = 0;
while (start < text.length) {
const maxEnd = Math.min(text.length, start + options.limit);
const preferredEnd =
options.mode === "preferred" && maxEnd < text.length
? findPreferredRangeEnd(text, start, maxEnd)
: undefined;
const candidateEnd = preferredEnd && preferredEnd > start ? preferredEnd : maxEnd;
const end = avoidTrailingHighSurrogateBreak(text, start, candidateEnd);
ranges.push({ start, end });
start = end;
}
return ranges;
}
/**
* Splits plain text into size-bounded chunks at readable boundaries.
*
* Returns the original text as one chunk when the limit is non-positive.
*/
export function chunkText(text: string, limit: number): string[] {
const early = resolveChunkEarlyReturn(text, limit);
if (early) {
return early;
}
const chunks: string[] = [];
let cursor = 0;
while (cursor < text.length) {
if (text.length - cursor <= limit) {
chunks.push(text.slice(cursor));
break;
}
const windowEnd = Math.min(text.length, cursor + limit);
const window = text.slice(cursor, windowEnd);
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window);
// Prefer block boundaries, then spaces, then a hard size cut when no
// readable breakpoint exists inside this window.
const breakOffset = lastNewline > 0 ? lastNewline : lastWhitespace;
const end = avoidTrailingHighSurrogateBreak(
text,
cursor,
breakOffset > 0 ? cursor + breakOffset : windowEnd,
);
chunks.push(text.slice(cursor, end));
cursor = end;
while (cursor < text.length && /\s/.test(text[cursor] ?? "")) {
cursor += 1;
}
}
return chunks;
}