Initial commit: Discord-Claude Gateway with event-driven agent runtime
This commit is contained in:
125
src/response-formatter.ts
Normal file
125
src/response-formatter.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* ResponseFormatter — splits long response text into Discord-safe chunks.
|
||||
*
|
||||
* Splits text into chunks of at most `maxLength` characters (default 2000).
|
||||
* Prefers splitting at line boundaries. Tracks open code blocks: if a split
|
||||
* occurs inside a code block, the chunk is closed with ``` and the next chunk
|
||||
* reopens with ``` (preserving the language tag).
|
||||
*/
|
||||
|
||||
const DEFAULT_MAX_LENGTH = 2000;
|
||||
const CODE_FENCE = "```";
|
||||
const CLOSING_SUFFIX = "\n" + CODE_FENCE;
|
||||
|
||||
/**
|
||||
* Split `text` into an array of chunks, each at most `maxLength` characters.
|
||||
*
|
||||
* - Prefers splitting at newline boundaries.
|
||||
* - If a code block spans a split boundary, the current chunk is closed with
|
||||
* ``` and the next chunk reopens with ``` (including the original language tag).
|
||||
* - Empty text returns an empty array.
|
||||
*/
|
||||
export function splitMessage(
|
||||
text: string,
|
||||
maxLength: number = DEFAULT_MAX_LENGTH,
|
||||
): string[] {
|
||||
if (text.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (text.length <= maxLength) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
let openFenceTag: string | null = null;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
// If we're continuing inside a code block, prepend the fence opener.
|
||||
const prefix = openFenceTag !== null ? openFenceTag + "\n" : "";
|
||||
|
||||
// If the remaining text (with prefix) fits, emit the final chunk.
|
||||
if (prefix.length + remaining.length <= maxLength) {
|
||||
chunks.push(prefix + remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
// Always reserve space for a potential closing fence suffix when splitting.
|
||||
// This guarantees the chunk stays within maxLength even if we need to close
|
||||
// a code block that was opened (or continued) in this chunk.
|
||||
const budget = maxLength - prefix.length - CLOSING_SUFFIX.length;
|
||||
|
||||
if (budget <= 0) {
|
||||
// Degenerate case: maxLength too small for overhead. Force progress.
|
||||
const take = Math.max(1, maxLength - prefix.length);
|
||||
chunks.push(prefix + remaining.slice(0, take));
|
||||
remaining = remaining.slice(take);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find a good split point within the budget.
|
||||
const splitIndex = findSplitPoint(remaining, budget);
|
||||
const chunk = remaining.slice(0, splitIndex);
|
||||
remaining = remaining.slice(splitIndex);
|
||||
|
||||
// Determine code-block state at the end of this chunk.
|
||||
const fenceState = computeFenceState(chunk, openFenceTag);
|
||||
|
||||
if (fenceState.insideCodeBlock) {
|
||||
chunks.push(prefix + chunk + CLOSING_SUFFIX);
|
||||
openFenceTag = fenceState.fenceTag;
|
||||
} else {
|
||||
chunks.push(prefix + chunk);
|
||||
openFenceTag = null;
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Find the best index to split `text` at, within `budget` characters.
|
||||
* Prefers the last newline boundary. Falls back to `budget` if no newline found.
|
||||
*/
|
||||
function findSplitPoint(text: string, budget: number): number {
|
||||
const region = text.slice(0, budget);
|
||||
const lastNewline = region.lastIndexOf("\n");
|
||||
|
||||
if (lastNewline > 0) {
|
||||
return lastNewline + 1;
|
||||
}
|
||||
|
||||
return budget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `chunk` for code fence toggles and determine whether we end inside
|
||||
* an open code block.
|
||||
*/
|
||||
function computeFenceState(
|
||||
chunk: string,
|
||||
initialFenceTag: string | null,
|
||||
): { insideCodeBlock: boolean; fenceTag: string | null } {
|
||||
let inside = initialFenceTag !== null;
|
||||
let fenceTag = initialFenceTag;
|
||||
|
||||
const fenceRegex = /^(`{3,})(.*)?$/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = fenceRegex.exec(chunk)) !== null) {
|
||||
const backticks = match[1];
|
||||
const langTag = (match[2] ?? "").trim();
|
||||
|
||||
if (!inside) {
|
||||
inside = true;
|
||||
fenceTag = langTag ? backticks + langTag : backticks;
|
||||
} else {
|
||||
inside = false;
|
||||
fenceTag = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { insideCodeBlock: inside, fenceTag: fenceTag };
|
||||
}
|
||||
Reference in New Issue
Block a user