feat: improve channel message chunking boundary quality

This commit is contained in:
William Valentin
2026-02-18 10:50:34 -08:00
parent 49f0e0598b
commit 7ca5d5bff5
3 changed files with 88 additions and 15 deletions
+17
View File
@@ -91,6 +91,23 @@ describe('splitMessage', () => {
expect(result[0]).toBe('ab\ncdefghij');
expect(result[1]).toBe('klmnopqrst');
});
it('splits on sentence boundaries when available', () => {
const text = 'First sentence is here. Second sentence follows with more detail.';
const result = splitMessage(text, 36);
expect(result[0]).toBe('First sentence is here.');
expect(result.length).toBeGreaterThan(1);
});
it('balances markdown code fences across chunks', () => {
const text = '```ts\\nconst value = 1;\\nconsole.log(value);\\n```\\nAfter code.';
const result = splitMessage(text, 22);
expect(result.length).toBeGreaterThan(1);
for (const chunk of result) {
const fenceCount = (chunk.match(/```/g) ?? []).length;
expect(fenceCount % 2).toBe(0);
}
});
});
describe('normalizeResetCommandText', () => {
+57 -13
View File
@@ -6,32 +6,76 @@ import type { PairingManager } from './pairing.js';
/**
* Split a long message into chunks that respect a platform's character limit.
* Prefers splitting at newlines, then spaces, then hard-cuts.
* Prefers splitting at natural boundaries:
* paragraph breaks, newlines, sentence endings, spaces, then hard-cuts.
* When code fences are split, chunks are balanced by closing/re-opening fences.
*/
export function splitMessage(text: string, maxLength: number): string[] {
const chunks: string[] = [];
let remaining = text;
const rawChunks: string[] = [];
let remaining = text.trim();
while (remaining.length > 0) {
if (remaining.length <= maxLength) {
chunks.push(remaining);
rawChunks.push(remaining);
break;
}
// Try to split at a newline within the allowed window
let splitIndex = remaining.lastIndexOf('\n', maxLength);
if (splitIndex === -1 || splitIndex < maxLength / 2) {
splitIndex = remaining.lastIndexOf(' ', maxLength);
}
if (splitIndex === -1 || splitIndex < maxLength / 2) {
splitIndex = maxLength;
const minAcceptable = Math.floor(maxLength * 0.45);
const window = remaining.slice(0, maxLength);
const paragraphSplit = window.lastIndexOf('\n\n');
const newlineSplit = window.lastIndexOf('\n');
const sentenceSplit = window.search(/[\.\!\?;:](?!.*[\.\!\?;:])/);
const spaceSplit = window.lastIndexOf(' ');
const sentenceIndex = sentenceSplit >= 0 ? sentenceSplit + 1 : -1;
const preferred = [paragraphSplit + 1, newlineSplit, sentenceIndex, spaceSplit];
let splitIndex = maxLength;
for (const candidate of preferred) {
if (Number.isInteger(candidate) && candidate > 0 && candidate >= minAcceptable) {
splitIndex = candidate;
break;
}
}
chunks.push(remaining.slice(0, splitIndex));
rawChunks.push(remaining.slice(0, splitIndex));
remaining = remaining.slice(splitIndex).trimStart();
}
return chunks;
return rebalanceMarkdownCodeFences(rawChunks);
}
function rebalanceMarkdownCodeFences(chunks: string[]): string[] {
const balanced: string[] = [];
let codeFenceOpen = false;
let rawParity = 0;
for (let i = 0; i < chunks.length; i++) {
const rawChunk = chunks[i];
let chunk = rawChunk;
if (codeFenceOpen && !chunk.startsWith('```')) {
chunk = `\`\`\`\n${chunk}`;
}
const rawFenceCount = (rawChunk.match(/```/g) ?? []).length;
rawParity = (rawParity + rawFenceCount) % 2;
const hasNextChunk = i < chunks.length - 1;
if (rawParity === 1 && hasNextChunk) {
chunk = `${chunk}\n\`\`\``;
codeFenceOpen = true;
} else {
codeFenceOpen = false;
}
balanced.push(chunk);
}
if (rawParity === 1 && balanced.length > 0) {
balanced[balanced.length - 1] = `${balanced[balanced.length - 1]}\n\`\`\``;
}
return balanced;
}
/**