/** * KHAELOR * File: src/tools/truncate.ts * Description: Middle-out truncation with explicit omission markers and spill reporting (TOOL_PROTOCOL §1.3). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { TruncationInfo } from "./types.js"; export interface TruncateLimits { maxLines: number; maxBytes: number; headLines: number; tailLines: number; } /** Byte length without importing node:buffer explicitly (UTF-8). */ export function utf8Length(text: string): number { return Buffer.byteLength(text, "utf8"); } export function formatCount(n: number): string { return n.toLocaleString("en-US"); } function formatKb(bytes: number): string { return `${Math.max(1, Math.round(bytes / 1024))} KB`; } /** The standard omission marker line (TOOL_PROTOCOL §1.3). */ export function omissionMarker(omittedLines: number, omittedBytes: number, spillPath: string): string { return `[... ${formatCount(omittedLines)} lines omitted (${formatKb(omittedBytes)}). Full output: ${spillPath} — read or grep that file for the rest.]`; } /** Does this text exceed the given limits? */ export function needsTruncation(text: string, limits: TruncateLimits): boolean { if (utf8Length(text) > limits.maxBytes) return true; return countLines(text) > limits.maxLines; } function countLines(text: string): number { if (text.length === 0) return 0; let count = 1; for (let i = 0; i < text.length; i++) { if (text[i] === "\n") count += 1; } return count; } export interface TruncatedText { text: string; info: TruncationInfo; } /** * Middle-out truncation: keep head + tail lines, insert one explicit marker * pointing at the spilled full output. Call only when needsTruncation() is true. */ export function truncateMiddleOut( text: string, limits: TruncateLimits, spillPath: string, ): TruncatedText { const lines = text.split("\n"); const originalLines = lines.length; const originalBytes = utf8Length(text); let head = lines.slice(0, limits.headLines); let tail = lines.slice(Math.max(limits.headLines, originalLines - limits.tailLines)); // Byte backstop for pathological single-line output: hard-slice by chars. const budget = limits.maxBytes; let headText = head.join("\n"); let tailText = tail.join("\n"); if (utf8Length(headText) + utf8Length(tailText) > budget) { const half = Math.floor(budget / 2); headText = headText.slice(0, half); tailText = tailText.slice(-half); head = headText.split("\n"); tail = tailText.split("\n"); } const omittedLines = Math.max(0, originalLines - head.length - tail.length); const omittedBytes = Math.max(0, originalBytes - utf8Length(headText) - utf8Length(tailText)); const marker = omissionMarker(omittedLines, omittedBytes, spillPath); const parts = tailText.length > 0 ? [headText, marker, tailText] : [headText, marker]; return { text: parts.join("\n"), info: { originalBytes, originalLines, shownHeadLines: head.length, shownTailLines: tail.length, omittedLines, spillPath, }, }; }