import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { homedir } from "node:os"; import { dirname, join, resolve, sep } from "node:path"; import { aggregateLinterOutput, aggregateTestOutput, compactGitOutput, detectLanguage, filterBuildOutput, filterSourceCode, smartTruncate, stripAnsiFast, truncate, } from "./techniques/index.js"; import { trackOutputSavings } from "./output-metrics.js"; import { mapTextContentBlocks, toRecord } from "./record-utils.js"; import type { RtkIntegrationConfig } from "./types.js"; interface ToolResultLikeEvent { toolName: string; input?: unknown; content?: unknown; } export interface ToolResultCompactionMetadata { applied: boolean; techniques: string[]; truncated: boolean; originalCharCount: number; compactedCharCount: number; originalLineCount: number; compactedLineCount: number; } export interface ToolResultCompactionOutcome { changed: boolean; content?: unknown[]; techniques: string[]; metadata?: ToolResultCompactionMetadata; } interface AnchoredReadLine { lineNumber: number; content: string; originalLine: string; } interface AnchorSafeReadLine { text: string; content: string; } interface AnchorSafeReadParts { prefixLines: string[]; anchoredLines: AnchoredReadLine[]; suffixLines: string[]; trailingNewline: boolean; } const LOSSY_TECHNIQUE_PREFIXES = [ "build", "test", "git", "linter", "search", "truncate", "smart-truncate", "source:", ] as const; const READ_EXACT_OUTPUT_LINE_THRESHOLD = 80; const READ_COMPACTION_BANNER_PREFIX = "[RTK compacted output:"; const ANCHORED_READ_LINE_MIN_MATCHES = 2; const ANCHORED_READ_LINE_MIN_RATIO = 0.5; const ANCHORED_READ_LINE_SAMPLE_LIMIT = 200; const ANCHORED_READ_LINE_PATTERNS = [ /^\s*(?:>>>|>>|[>+\-*]+)?\s*(\d+)\s*#\s*[A-Za-z0-9_-]{2,32}:(.*)$/, /^\s*(?:>>>|>>|[>+\-*]+)?\s*(\d+)\s*:\s*[A-Za-z0-9_-]{1,32}\|(.*)$/, /^\s*(?:>>>|>>|[>+\-*]+)?\s*(\d+)[a-z]{2}\|(.*)$/, ] as const; const ANCHORED_READ_INFORMATIONAL_LINE_PATTERN = /^\s*(?:$|<\/?file>|\.{3}|\[[^\]]+\]|Read\s+.+:\s+\d+\s+lines\b)/; const USER_SKILL_ROOTS = [join(getAgentDir(), "skills"), join(homedir(), ".agents", "skills")]; function normalizePathForComparison(path: string): string { return process.platform === "win32" ? path.toLowerCase() : path; } function isPathUnderRoot(targetPath: string, rootPath: string): boolean { const normalizedTarget = normalizePathForComparison(resolve(targetPath)); const normalizedRoot = normalizePathForComparison(resolve(rootPath)); if (normalizedTarget === normalizedRoot) { return true; } const rootWithSeparator = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; return normalizedTarget.startsWith(rootWithSeparator); } function isUnderAnyAncestorAgentsSkills(targetPath: string): boolean { let currentDir = resolve(process.cwd()); while (true) { if (isPathUnderRoot(targetPath, join(currentDir, ".agents", "skills"))) { return true; } const parentDir = dirname(currentDir); if (parentDir === currentDir) { return false; } currentDir = parentDir; } } function isSkillReadPath(filePath: string): boolean { if (!filePath.trim()) { return false; } const resolvedPath = resolve(filePath); if (USER_SKILL_ROOTS.some((root) => isPathUnderRoot(resolvedPath, root))) { return true; } if (isPathUnderRoot(resolvedPath, join(process.cwd(), ".pi", "skills"))) { return true; } return isUnderAnyAncestorAgentsSkills(resolvedPath); } function toArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } function normalizeCommand(input: Record): string | undefined { const raw = input.command; if (typeof raw === "string" && raw.trim()) { return raw; } return undefined; } function normalizePath(input: Record): string { const raw = input.path; if (typeof raw === "string") { return raw; } return ""; } function hasExplicitReadRange(input: Record): boolean { return input.offset !== undefined || input.limit !== undefined; } function splitReadLines(text: string): { lines: string[]; trailingNewline: boolean } { if (!text) { return { lines: [], trailingNewline: false }; } const trailingNewline = text.endsWith("\n"); const lines = text.split(/\r?\n/); if (trailingNewline) { lines.pop(); } return { lines, trailingNewline }; } function joinReadLines(lines: string[], trailingNewline: boolean): string { const joined = lines.join("\n"); return trailingNewline && joined ? `${joined}\n` : joined; } function parseAnchoredReadLine(line: string): AnchoredReadLine | undefined { for (const pattern of ANCHORED_READ_LINE_PATTERNS) { const match = line.match(pattern); if (!match) { continue; } const lineNumber = Number.parseInt(match[1] ?? "", 10); if (!Number.isSafeInteger(lineNumber) || lineNumber <= 0) { continue; } const content = match[2] ?? ""; return { lineNumber, content, originalLine: line, }; } return undefined; } function parseAnchoredReadLineNumber(line: string): number | undefined { return parseAnchoredReadLine(line)?.lineNumber; } function looksLikeAnchoredLineOutput(text: string, parseLineNumber: (line: string) => number | undefined): boolean { let matchCount = 0; let relevantLineCount = 0; let previousMatchedLineNumber: number | undefined; let hasIncreasingAnchors = false; for (const line of splitReadLines(text).lines.slice(0, ANCHORED_READ_LINE_SAMPLE_LIMIT)) { if (!ANCHORED_READ_INFORMATIONAL_LINE_PATTERN.test(line)) { relevantLineCount += 1; } const lineNumber = parseLineNumber(line); if (lineNumber === undefined) { continue; } matchCount += 1; if (previousMatchedLineNumber !== undefined && lineNumber > previousMatchedLineNumber) { hasIncreasingAnchors = true; } previousMatchedLineNumber = lineNumber; } if (matchCount < ANCHORED_READ_LINE_MIN_MATCHES || !hasIncreasingAnchors) { return false; } const ratioBase = Math.max(relevantLineCount, matchCount); return matchCount / ratioBase >= ANCHORED_READ_LINE_MIN_RATIO; } function looksLikeAnchoredReadOutput(text: string): boolean { return looksLikeAnchoredLineOutput(text, parseAnchoredReadLineNumber); } function shouldPreserveExactReadOutput( text: string, input: Record, config: RtkIntegrationConfig, ): boolean { if (!config.outputCompaction.readCompaction.enabled) { return true; } if (hasExplicitReadRange(input)) { return true; } if (config.outputCompaction.preserveExactSkillReads && isSkillReadPath(normalizePath(input))) { return true; } return countLines(text) <= READ_EXACT_OUTPUT_LINE_THRESHOLD; } function shouldApplyReadSourceFiltering(text: string, config: RtkIntegrationConfig): boolean { const compaction = config.outputCompaction; const lineCount = countLines(text); return ( (compaction.smartTruncate.enabled && lineCount > compaction.smartTruncate.maxLines) || (compaction.truncate.enabled && text.length > compaction.truncate.maxChars) ); } function extractAnchoredReadParts(text: string): AnchorSafeReadParts | undefined { if (!looksLikeAnchoredReadOutput(text)) { return undefined; } const { lines, trailingNewline } = splitReadLines(text); const parsedLines = lines.map((line) => parseAnchoredReadLine(line)); const firstAnchorIndex = parsedLines.findIndex((line) => line !== undefined); if (firstAnchorIndex === -1) { return undefined; } let lastAnchorIndex = firstAnchorIndex; for (let index = parsedLines.length - 1; index >= firstAnchorIndex; index -= 1) { if (parsedLines[index] !== undefined) { lastAnchorIndex = index; break; } } const anchoredLines: AnchoredReadLine[] = []; for (let index = firstAnchorIndex; index <= lastAnchorIndex; index += 1) { const anchoredLine = parsedLines[index]; if (!anchoredLine) { return undefined; } anchoredLines.push(anchoredLine); } return { prefixLines: lines.slice(0, firstAnchorIndex), anchoredLines, suffixLines: lines.slice(lastAnchorIndex + 1), trailingNewline, }; } function toAnchorSafeReadLines(anchoredLines: AnchoredReadLine[]): AnchorSafeReadLine[] { return anchoredLines.map((line) => ({ text: line.originalLine, content: line.content, })); } function renderAnchorSafeReadBody(lines: AnchorSafeReadLine[]): string { return lines.map((line) => line.text).join("\n"); } function renderAnchorSafeReadText(parts: AnchorSafeReadParts, lines: AnchorSafeReadLine[]): string { return joinReadLines( [...parts.prefixLines, ...lines.map((line) => line.text), ...parts.suffixLines], parts.trailingNewline, ); } function remapTransformedContentToAnchorSafeLines( sourceLines: AnchorSafeReadLine[], transformedContent: string, ): AnchorSafeReadLine[] { const transformedLines = splitReadLines(transformedContent).lines; const remappedLines: AnchorSafeReadLine[] = []; let searchStartIndex = 0; for (const transformedLine of transformedLines) { let matchedIndex = -1; for (let index = searchStartIndex; index < sourceLines.length; index += 1) { if (sourceLines[index]?.content === transformedLine) { matchedIndex = index; break; } } if (matchedIndex === -1) { remappedLines.push({ text: transformedLine, content: transformedLine, }); continue; } remappedLines.push(sourceLines[matchedIndex]!); searchStartIndex = matchedIndex + 1; } return remappedLines; } function truncateAnchorSafeReadLines(lines: AnchorSafeReadLine[], maxChars: number): AnchorSafeReadLine[] { if (renderAnchorSafeReadBody(lines).length <= maxChars) { return lines; } const marker = "[RTK anchor-safe truncate: remaining anchored read lines omitted to preserve complete anchors]"; const truncatedLines: AnchorSafeReadLine[] = []; let charCount = 0; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]!; const separatorLength = truncatedLines.length > 0 ? 1 : 0; const nextCharCount = charCount + separatorLength + line.text.length; const remainingAfter = lines.length - index - 1; const markerLength = remainingAfter > 0 ? (nextCharCount > 0 ? 1 : 0) + marker.length : 0; if (nextCharCount + markerLength > maxChars) { const markerLine = { text: marker, content: marker }; return truncatedLines.length > 0 ? [...truncatedLines, markerLine] : [markerLine]; } truncatedLines.push(line); charCount = nextCharCount; } return truncatedLines; } function compactAnchoredReadText( text: string, filePath: string, config: RtkIntegrationConfig, ): { text: string; techniques: string[] } { const parts = extractAnchoredReadParts(text); if (!parts) { return { text, techniques: [] }; } let lines = toAnchorSafeReadLines(parts.anchoredLines); const techniques: string[] = []; const compaction = config.outputCompaction; const language = detectLanguage(filePath); if ( compaction.sourceCodeFilteringEnabled && compaction.sourceCodeFiltering !== "none" && shouldApplyReadSourceFiltering(text, config) ) { const currentSource = lines.map((line) => line.content).join("\n"); const filtered = normalizeTechniqueResult( filterSourceCode(currentSource, language, compaction.sourceCodeFiltering), currentSource, ); const filteredLines = remapTransformedContentToAnchorSafeLines(lines, filtered); if (renderAnchorSafeReadBody(filteredLines) !== renderAnchorSafeReadBody(lines)) { lines = filteredLines; techniques.push(`source:${compaction.sourceCodeFiltering}`); } } if (compaction.smartTruncate.enabled && lines.length > compaction.smartTruncate.maxLines) { const currentSource = lines.map((line) => line.content).join("\n"); const compacted = smartTruncate(currentSource, compaction.smartTruncate.maxLines, language); const compactedLines = remapTransformedContentToAnchorSafeLines(lines, compacted); if (renderAnchorSafeReadBody(compactedLines) !== renderAnchorSafeReadBody(lines)) { lines = compactedLines; techniques.push("smart-truncate"); } } if (compaction.truncate.enabled && renderAnchorSafeReadText(parts, lines).length > compaction.truncate.maxChars) { const nonBodyOverhead = renderAnchorSafeReadText(parts, []).length; const bodyMaxChars = Math.max(1, compaction.truncate.maxChars - nonBodyOverhead); const truncatedLines = truncateAnchorSafeReadLines(lines, bodyMaxChars); if (renderAnchorSafeReadBody(truncatedLines) !== renderAnchorSafeReadBody(lines)) { lines = truncatedLines; techniques.push("truncate"); } } return { text: renderAnchorSafeReadText(parts, lines), techniques, }; } function formatReadCompactionBanner(techniques: string[]): string { return `${READ_COMPACTION_BANNER_PREFIX} ${techniques.join(", ")}]`; } function countLines(text: string): number { if (!text) { return 0; } const normalized = text.endsWith("\n") ? text.slice(0, -1) : text; if (!normalized) { return 1; } return normalized.split("\n").length; } function hasLossyCompaction(techniques: string[]): boolean { return techniques.some((technique) => LOSSY_TECHNIQUE_PREFIXES.some((prefix) => prefix.endsWith(":") ? technique.startsWith(prefix) : technique === prefix, ), ); } function normalizeTechniqueResult(result: string | null, currentText: string): string { return result === null ? currentText : result; } interface CompactionState { text: string; techniques: string[]; } /** Strips ANSI escape codes when enabled, recording the "ansi" technique on change. */ function applyAnsiStripping(state: CompactionState, compaction: RtkIntegrationConfig["outputCompaction"]): void { if (!compaction.stripAnsi) { return; } const stripped = stripAnsiFast(state.text); if (stripped !== state.text) { state.text = stripped; state.techniques.push("ansi"); } } /** Applies hard character truncation when enabled and the threshold is exceeded. */ function applyTruncation(state: CompactionState, compaction: RtkIntegrationConfig["outputCompaction"]): void { if (compaction.truncate.enabled && state.text.length > compaction.truncate.maxChars) { state.text = truncate(state.text, compaction.truncate.maxChars); state.techniques.push("truncate"); } } /** * Applies a single nullable-result compaction technique: runs `transform`, keeps * its result when it differs from the current text, and records `technique`. * Mirrors the `normalizeTechniqueResult(...) !== current → push` idiom shared * across the bash/read compactors. */ function applyNullableTechnique( state: CompactionState, transform: (text: string) => string | null, technique: string, ): void { const compacted = normalizeTechniqueResult(transform(state.text), state.text); if (compacted !== state.text) { state.text = compacted; state.techniques.push(technique); } } function applyConditionalTechnique( state: CompactionState, enabled: boolean, transform: (text: string) => string | null, technique: string, ): void { if (enabled) { applyNullableTechnique(state, transform, technique); } } function beginCompaction( text: string, config: RtkIntegrationConfig, ): { state: CompactionState; compaction: RtkIntegrationConfig["outputCompaction"] } { const state: CompactionState = { text, techniques: [] }; const compaction = config.outputCompaction; applyAnsiStripping(state, compaction); return { state, compaction }; } function applyReadCompactionBanner(state: CompactionState): void { if (state.techniques.length > 0 && !state.text.startsWith(READ_COMPACTION_BANNER_PREFIX)) { state.text = `${formatReadCompactionBanner(state.techniques)}\n${state.text}`; } } function compactBashText( text: string, command: string | undefined, config: RtkIntegrationConfig, ): { text: string; techniques: string[] } { const { state, compaction } = beginCompaction(text, config); applyConditionalTechnique(state, compaction.filterBuildOutput, (t) => filterBuildOutput(t, command), "build"); applyConditionalTechnique(state, compaction.aggregateTestOutput, (t) => aggregateTestOutput(t, command), "test"); applyConditionalTechnique(state, compaction.compactGitOutput, (t) => compactGitOutput(t, command), "git"); applyConditionalTechnique(state, compaction.aggregateLinterOutput, (t) => aggregateLinterOutput(t, command), "linter"); applyTruncation(state, compaction); return { text: state.text, techniques: state.techniques }; } function compactReadText( text: string, filePath: string, config: RtkIntegrationConfig, preserveExactReadOutput: boolean, ): { text: string; techniques: string[] } { if (preserveExactReadOutput) { return { text, techniques: [] }; } const { state, compaction } = beginCompaction(text, config); if (looksLikeAnchoredReadOutput(state.text)) { const anchored = compactAnchoredReadText(state.text, filePath, config); state.text = anchored.text; state.techniques.push(...anchored.techniques); applyReadCompactionBanner(state); return { text: state.text, techniques: state.techniques }; } const language = detectLanguage(filePath); // Only apply lossy source filtering when a downstream line/char safeguard would otherwise trigger. if ( compaction.sourceCodeFilteringEnabled && compaction.sourceCodeFiltering !== "none" && shouldApplyReadSourceFiltering(text, config) ) { applyNullableTechnique( state, (t) => filterSourceCode(t, language, compaction.sourceCodeFiltering), `source:${compaction.sourceCodeFiltering}`, ); } if (compaction.smartTruncate.enabled) { const lineCount = state.text.split("\n").length; if (lineCount > compaction.smartTruncate.maxLines) { const compacted = smartTruncate(state.text, compaction.smartTruncate.maxLines, language); if (compacted !== state.text) { state.text = compacted; state.techniques.push("smart-truncate"); } } } applyTruncation(state, compaction); applyReadCompactionBanner(state); return { text: state.text, techniques: state.techniques }; } export function compactToolResult( event: ToolResultLikeEvent, config: RtkIntegrationConfig, ): ToolResultCompactionOutcome { if (!config.outputCompaction.enabled) { return { changed: false, techniques: [] }; } const input = toRecord(event.input); const sourceContent = toArray(event.content); if (sourceContent.length === 0) { return { changed: false, techniques: [] }; } const allTechniques = new Set(); const originalChunks: string[] = []; const filteredChunks: string[] = []; const { changed, mapped: nextContent } = mapTextContentBlocks(sourceContent, (contentBlock) => { let transformed = { text: contentBlock.text, techniques: [] as string[] }; if (event.toolName === "bash" || event.toolName === "ssh_bash") { transformed = compactBashText(contentBlock.text, normalizeCommand(input), config); } else if (event.toolName === "read") { const normalizedPath = normalizePath(input); transformed = compactReadText( contentBlock.text, normalizedPath, config, shouldPreserveExactReadOutput(contentBlock.text, input, config), ); } for (const technique of transformed.techniques) { allTechniques.add(technique); } originalChunks.push(contentBlock.text); filteredChunks.push(transformed.text); return transformed.text !== contentBlock.text ? transformed.text : null; }); if (!changed) { return { changed: false, techniques: [] }; } const techniques = Array.from(allTechniques); const originalText = originalChunks.join("\n"); const compactedText = filteredChunks.join("\n"); if (config.outputCompaction.trackSavings) { trackOutputSavings(originalText, compactedText, event.toolName, techniques); } const metadata: ToolResultCompactionMetadata = { applied: true, techniques, truncated: hasLossyCompaction(techniques), originalCharCount: originalText.length, compactedCharCount: compactedText.length, originalLineCount: countLines(originalText), compactedLineCount: countLines(compactedText), }; return { changed: true, content: nextContent, techniques, metadata, }; }