openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
326 lines (325 loc) • 13.4 kB
JavaScript
import { n as sliceUtf16Safe } from "./utf16-slice-D_ngcYKd.js";
import { n as isSafeFenceBreak, r as parseFenceSpans, t as findFenceSpanAt } from "./fences-DtD_oijY.js";
//#region src/agents/embedded-agent-block-chunker.ts
/**
* Splits streamed embedded-agent replies into Markdown-safe message chunks.
*/
function findSafeSentenceBreakIndex(text, fenceSpans, minChars, offset = 0) {
const matches = text.matchAll(/[.!?](?=\s|$)/g);
let sentenceIdx = -1;
for (const match of matches) {
const at = match.index ?? -1;
if (at < minChars) continue;
const candidate = at + 1;
if (isSafeFenceBreak(fenceSpans, offset + candidate)) sentenceIdx = candidate;
}
return sentenceIdx >= minChars ? sentenceIdx : -1;
}
function findSafeParagraphBreakIndex(params) {
const { text, fenceSpans, minChars, reverse, offset = 0 } = params;
let paragraphIdx = reverse ? text.lastIndexOf("\n\n") : text.indexOf("\n\n");
while (reverse ? paragraphIdx >= minChars : paragraphIdx !== -1) {
const candidates = [paragraphIdx, paragraphIdx + 1];
for (const candidate of candidates) {
if (candidate < minChars) continue;
if (candidate < 0 || candidate >= text.length) continue;
if (isSafeFenceBreak(fenceSpans, offset + candidate)) return candidate;
}
paragraphIdx = reverse ? text.lastIndexOf("\n\n", paragraphIdx - 1) : text.indexOf("\n\n", paragraphIdx + 2);
}
return -1;
}
function findSafeNewlineBreakIndex(params) {
const { text, fenceSpans, minChars, reverse, offset = 0 } = params;
let newlineIdx = reverse ? text.lastIndexOf("\n") : text.indexOf("\n");
while (reverse ? newlineIdx >= minChars : newlineIdx !== -1) {
if (newlineIdx >= minChars && isSafeFenceBreak(fenceSpans, offset + newlineIdx)) return newlineIdx;
newlineIdx = reverse ? text.lastIndexOf("\n", newlineIdx - 1) : text.indexOf("\n", newlineIdx + 1);
}
return -1;
}
function findFenceCloseLineStart(buffer, fence, offset = 0) {
const relativeFenceEnd = Math.min(buffer.length, Math.max(0, fence.end - offset));
if (relativeFenceEnd <= 0) return -1;
const lastNewline = buffer.lastIndexOf("\n", relativeFenceEnd - 1);
if (lastNewline < 0) return -1;
const closingMarker = buffer.slice(lastNewline + 1, relativeFenceEnd).match(/^ {0,3}(`{3,}|~{3,})[ \t]*\r?$/)?.[1];
return closingMarker && closingMarker.charAt(0) === fence.marker.charAt(0) && closingMarker.length >= fence.marker.length ? lastNewline + 1 : -1;
}
function resolveFenceReopenLine(fence, maxChars) {
const bareMarker = `${fence.indent}${fence.marker}`;
if (bareMarker.length * 2 + 3 > maxChars) return;
return fence.openLine.length + bareMarker.length + 3 <= maxChars ? fence.openLine : bareMarker;
}
var EmbeddedBlockChunker = class {
#buffer = "";
#reopenPrefix = "";
#consumedLength = 0;
#chunking;
constructor(chunking) {
this.#chunking = chunking;
}
/** Add streamed text to the pending chunk buffer. */
append(text) {
if (!text) return;
this.#buffer += text;
}
/** Start a new source scope without emitting pending text. */
reset() {
this.#buffer = "";
this.#reopenPrefix = "";
this.#consumedLength = 0;
}
/** UTF-16 source positions exclude synthetic fences and include skipped whitespace. */
get consumedLength() {
return this.#consumedLength;
}
get sourceLength() {
return this.#consumedLength + this.#buffer.length;
}
/** Replace a suffix in the original input projection; never replay drained source. */
replace(text, sourceOffset = 0) {
const pendingOffset = sourceOffset - this.#consumedLength;
const next = this.#buffer.slice(0, Math.max(0, pendingOffset)) + text.slice(Math.max(0, -pendingOffset));
const changed = next !== this.#buffer;
this.#buffer = next;
this.#consumedLength = Math.min(this.#consumedLength, sourceOffset + text.length);
return changed;
}
/** Return the currently buffered text for tests and flush logic. */
get bufferedText() {
return this.#buffer ? `${this.#reopenPrefix}${this.#buffer}` : "";
}
/** Return true when there is pending text to drain. */
hasBuffered() {
return this.#buffer.length > 0;
}
/** Emit safe chunks according to size and Markdown fence constraints. */
drain(params) {
const { force, emit } = params;
const chunking = this.#chunking;
if (!this.#buffer || !force && !chunking) return;
const minChars = Math.max(1, Math.floor(chunking?.minChars ?? 1));
const maxChars = Math.max(minChars, Math.floor(chunking?.maxChars ?? Infinity));
let source = this.bufferedText;
if (source.length < minChars && !force) return;
if (!chunking || force && source.length <= maxChars && !this.#reopenPrefix) {
if (!chunking || source.trim().length > 0) emit(source);
this.#consumedLength += this.#buffer.length;
this.#buffer = "";
this.#reopenPrefix = "";
return;
}
const fenceSpans = parseFenceSpans(source);
const removedFenceInfo = [];
let removedFenceInfoLength = 0;
for (const fence of fenceSpans) {
fence.start -= removedFenceInfoLength;
fence.end -= removedFenceInfoLength;
const reopenFenceLine = resolveFenceReopenLine(fence, maxChars);
if (!reopenFenceLine || reopenFenceLine === fence.openLine || fence.end - fence.start <= maxChars) continue;
source = source.slice(0, fence.start) + reopenFenceLine + source.slice(fence.start + fence.openLine.length);
const removedLength = fence.openLine.length - reopenFenceLine.length;
removedFenceInfo.push({
at: fence.start + reopenFenceLine.length,
length: removedLength
});
fence.openLine = reopenFenceLine;
fence.end -= removedLength;
removedFenceInfoLength += removedLength;
}
let start = 0;
let reopenFence;
const resumedFence = this.#reopenPrefix ? fenceSpans[0] : void 0;
if (resumedFence) {
const closeStart = findFenceCloseLineStart(source, resumedFence);
if (closeStart >= this.#reopenPrefix.length && !source.slice(this.#reopenPrefix.length, closeStart).trim()) start = skipLeadingNewlines(source, resumedFence.end);
}
while (start < source.length) {
const reopenPrefix = reopenFence ? `${reopenFence.reopenFenceLine}\n` : "";
const remainingLength = reopenPrefix.length + (source.length - start);
if (!force && remainingLength < minChars) break;
if (chunking.flushOnParagraph && !force) {
const paragraphBreak = findNextParagraphBreak(source, fenceSpans, start, minChars);
const paragraphLimit = Math.max(1, maxChars - reopenPrefix.length);
if (paragraphBreak && paragraphBreak.index - start <= paragraphLimit) {
const chunk = `${reopenPrefix}${source.slice(start, paragraphBreak.index)}`;
if (chunk.trim().length > 0) emit(chunk);
start = skipLeadingNewlines(source, paragraphBreak.index + paragraphBreak.length);
reopenFence = void 0;
continue;
}
if (remainingLength < maxChars) break;
}
const view = source.slice(start);
const breakResult = force && remainingLength <= maxChars ? this.#pickSoftBreakIndex(view, fenceSpans, chunking, 1, start) : this.#pickBreakIndex(view, fenceSpans, chunking, force ? 1 : void 0, start, maxChars - reopenPrefix.length);
if (breakResult.index <= 0) {
if (force) {
emit(`${reopenPrefix}${source.slice(start)}`);
start = source.length;
reopenFence = void 0;
}
break;
}
const consumed = this.#emitBreakResult({
breakResult,
emit,
reopenPrefix,
source,
start
});
if (consumed === null) continue;
start = consumed.start;
reopenFence = consumed.reopenFence;
const nextLength = (reopenFence ? `${reopenFence.reopenFenceLine}\n`.length : 0) + (source.length - start);
if (nextLength < minChars && !force) break;
if (nextLength < maxChars && !force && !chunking.flushOnParagraph) break;
}
if (!reopenFence) start = skipLeadingNewlines(source, start);
if (start === 0) return;
const sourceStart = removedFenceInfo.reduce((offset, removed) => offset + (removed.at <= start ? removed.length : 0), start);
const consumed = Math.max(0, sourceStart - this.#reopenPrefix.length);
this.#consumedLength += consumed;
this.#buffer = this.#buffer.slice(consumed);
this.#reopenPrefix = reopenFence ? `${reopenFence.reopenFenceLine}\n` : "";
}
#emitBreakResult(params) {
const { breakResult, emit, reopenPrefix, source, start } = params;
const breakIdx = breakResult.index;
if (breakIdx <= 0) return null;
const absoluteBreakIdx = start + breakIdx;
let rawChunk = `${reopenPrefix}${source.slice(start, absoluteBreakIdx)}`;
if (rawChunk.trim().length === 0) return {
start: skipLeadingNewlines(source, absoluteBreakIdx),
reopenFence: void 0
};
const fenceSplit = breakResult.fenceSplit;
if (fenceSplit) {
const closeFence = rawChunk.endsWith("\n") ? fenceSplit.closeFenceLine : `\n${fenceSplit.closeFenceLine}`;
rawChunk = `${rawChunk}${closeFence}`;
}
emit(rawChunk);
if (fenceSplit) {
if (absoluteBreakIdx === findFenceCloseLineStart(source, fenceSplit.fence)) return { start: skipLeadingNewlines(source, fenceSplit.fence.end) };
return {
start: absoluteBreakIdx,
reopenFence: fenceSplit
};
}
return {
start: skipLeadingNewlines(source, absoluteBreakIdx < source.length && /\s/.test(source.charAt(absoluteBreakIdx)) ? absoluteBreakIdx + 1 : absoluteBreakIdx),
reopenFence: void 0
};
}
#pickSoftBreakIndex(buffer, fenceSpans, chunking, minCharsOverride, offset = 0) {
const minChars = Math.max(1, Math.floor(minCharsOverride ?? chunking.minChars));
if (buffer.length < minChars) return { index: -1 };
const preference = chunking.breakPreference ?? "paragraph";
if (preference === "paragraph") {
const paragraphIdx = findSafeParagraphBreakIndex({
text: buffer,
fenceSpans,
minChars,
reverse: false,
offset
});
if (paragraphIdx !== -1) return { index: paragraphIdx };
}
if (preference === "paragraph" || preference === "newline") {
const newlineIdx = findSafeNewlineBreakIndex({
text: buffer,
fenceSpans,
minChars,
reverse: false,
offset
});
if (newlineIdx !== -1) return { index: newlineIdx };
}
if (preference !== "newline") {
const sentenceIdx = findSafeSentenceBreakIndex(buffer, fenceSpans, minChars, offset);
if (sentenceIdx !== -1) return { index: sentenceIdx };
}
return { index: -1 };
}
#pickBreakIndex(buffer, fenceSpans, chunking, minCharsOverride, offset = 0, maxCharsOverride) {
const minChars = Math.max(1, Math.floor(minCharsOverride ?? chunking.minChars));
const maxChars = Math.max(1, Math.floor(maxCharsOverride ?? chunking.maxChars));
if (buffer.length < minChars) return { index: -1 };
const window = buffer.slice(0, Math.min(maxChars, buffer.length));
const preference = chunking.breakPreference ?? "paragraph";
if (preference === "paragraph") {
const paragraphIdx = findSafeParagraphBreakIndex({
text: window,
fenceSpans,
minChars,
reverse: true,
offset
});
if (paragraphIdx !== -1) return { index: paragraphIdx };
}
if (preference === "paragraph" || preference === "newline") {
const newlineIdx = findSafeNewlineBreakIndex({
text: window,
fenceSpans,
minChars,
reverse: true,
offset
});
if (newlineIdx !== -1) return { index: newlineIdx };
}
if (preference !== "newline") {
const sentenceIdx = findSafeSentenceBreakIndex(window, fenceSpans, minChars, offset);
if (sentenceIdx !== -1) return { index: sentenceIdx };
}
if (preference === "newline" && buffer.length < maxChars) return { index: -1 };
for (let i = window.length - 1; i >= minChars; i--) if (/\s/.test(window.charAt(i)) && isSafeFenceBreak(fenceSpans, offset + i)) return { index: i };
if (buffer.length >= maxChars) {
const firstCodePointWidth = (buffer.codePointAt(0) ?? 0) > 65535 ? 2 : 1;
const forcedBreakIndex = sliceUtf16Safe(buffer, 0, Math.max(maxChars, firstCodePointWidth)).length;
if (isSafeFenceBreak(fenceSpans, offset + forcedBreakIndex)) return { index: forcedBreakIndex };
const fence = findFenceSpanAt(fenceSpans, offset + forcedBreakIndex);
if (fence) {
const reopenFenceLine = resolveFenceReopenLine(fence, chunking.maxChars);
if (!reopenFenceLine) return { index: forcedBreakIndex };
const closeFenceLine = `${fence.indent}${fence.marker}`;
const fenceBreakIndex = sliceUtf16Safe(buffer, 0, Math.max(1, maxChars - closeFenceLine.length - 1)).length;
if (fenceBreakIndex <= 0) return { index: forcedBreakIndex };
const closeFenceStart = findFenceCloseLineStart(buffer, fence, offset);
return {
index: closeFenceStart >= minChars && closeFenceStart <= fenceBreakIndex ? closeFenceStart : fenceBreakIndex,
fenceSplit: {
closeFenceLine,
reopenFenceLine,
fence
}
};
}
return { index: forcedBreakIndex };
}
return { index: -1 };
}
};
function skipLeadingNewlines(value, start = 0) {
let i = start;
while (i < value.length && value[i] === "\n") i++;
return i;
}
function findNextParagraphBreak(buffer, fenceSpans, startIndex = 0, minCharsFromStart = 1) {
if (startIndex < 0) return null;
const re = /\n[\t ]*\n+/g;
re.lastIndex = startIndex;
let match;
while ((match = re.exec(buffer)) !== null) {
const index = match.index ?? -1;
if (index < 0) continue;
if (index - startIndex < minCharsFromStart) continue;
if (!isSafeFenceBreak(fenceSpans, index)) continue;
return {
index,
length: match[0].length
};
}
return null;
}
//#endregion
export { EmbeddedBlockChunker as t };