mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
681 lines
20 KiB
TypeScript
681 lines
20 KiB
TypeScript
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, unknown>): string | undefined {
|
|
const raw = input.command;
|
|
if (typeof raw === "string" && raw.trim()) {
|
|
return raw;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function normalizePath(input: Record<string, unknown>): string {
|
|
const raw = input.path;
|
|
if (typeof raw === "string") {
|
|
return raw;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function hasExplicitReadRange(input: Record<string, unknown>): 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<string, unknown>,
|
|
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<string>();
|
|
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,
|
|
};
|
|
}
|