284 lines
8.6 KiB
TypeScript
284 lines
8.6 KiB
TypeScript
import type { ToolStrategiesConfig } from "./types.ts";
|
|
|
|
export interface CompressionResult {
|
|
compressed: string;
|
|
strategy: "fileContent" | "grepSearch" | "shellOutput" | "json" | "errorMessage" | "none";
|
|
saved: number;
|
|
}
|
|
|
|
const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
|
|
const SHELL_PROMPT_RE = /\$\s/;
|
|
const JSON_PREFIX_RE = /^\s*[{[]/;
|
|
const COMPRESSED_MARKER_RE = /^\[COMPRESSED:/;
|
|
|
|
function isCodeLikeLine(rawLine: string): boolean {
|
|
const line = rawLine.trimStart();
|
|
return (
|
|
line.startsWith("import ") ||
|
|
line.startsWith("export ") ||
|
|
line.startsWith("function ") ||
|
|
line.startsWith("class ") ||
|
|
line.startsWith("const ") ||
|
|
line.startsWith("let ") ||
|
|
line.startsWith("var ") ||
|
|
line.startsWith("return ") ||
|
|
line.startsWith("if(") ||
|
|
line.startsWith("if (") ||
|
|
line.startsWith("for(") ||
|
|
line.startsWith("for (") ||
|
|
line.startsWith("while(") ||
|
|
line.startsWith("while (")
|
|
);
|
|
}
|
|
|
|
function parseGrepLinePath(line: string): string | null {
|
|
const firstColon = line.indexOf(":");
|
|
if (firstColon <= 0) return null;
|
|
|
|
const secondColon = line.indexOf(":", firstColon + 1);
|
|
if (secondColon === -1) return null;
|
|
|
|
const lineNumber = line.slice(firstColon + 1, secondColon);
|
|
if (!lineNumber || ![...lineNumber].every((char) => char >= "0" && char <= "9")) {
|
|
return null;
|
|
}
|
|
|
|
const filePath = line.slice(0, firstColon);
|
|
if (!filePath || /\s/.test(filePath)) return null;
|
|
return filePath;
|
|
}
|
|
|
|
function hasErrorLikeOutput(content: string): boolean {
|
|
const lower = content.toLowerCase();
|
|
return (
|
|
lower.includes("error:") ||
|
|
lower.includes("error ") ||
|
|
lower.includes("[error]") ||
|
|
lower.includes("exception:") ||
|
|
lower.includes("exception ") ||
|
|
lower.includes("[exception]") ||
|
|
lower.includes("traceback")
|
|
);
|
|
}
|
|
|
|
function compressFileContent(content: string): string | null {
|
|
const lines = content.split("\n");
|
|
if (lines.length < 3) return null;
|
|
if (!lines.some(isCodeLikeLine)) return null;
|
|
const keep = 20;
|
|
const tail = 5;
|
|
if (lines.length <= keep + tail) return content;
|
|
const head = lines.slice(0, keep).join("\n");
|
|
const tailLines = lines.slice(-tail).join("\n");
|
|
const elided = lines.length - keep - tail;
|
|
return `${head}\n… [${elided} lines elided] …\n${tailLines}`;
|
|
}
|
|
|
|
function compressGrepSearch(content: string): string | null {
|
|
const lines = content.split("\n");
|
|
const grepLines = lines.filter((line) => parseGrepLinePath(line) !== null);
|
|
if (grepLines.length === 0) return null;
|
|
const paths = new Set<string>();
|
|
for (const line of grepLines) {
|
|
const filePath = parseGrepLinePath(line);
|
|
if (filePath) paths.add(filePath);
|
|
}
|
|
const top30 = grepLines.slice(0, 30);
|
|
const remaining = grepLines.length - top30.length;
|
|
let result = top30.join("\n");
|
|
if (remaining > 0) {
|
|
result += `\n… [${remaining} more matches]`;
|
|
}
|
|
result += `\nFiles: ${[...paths].join(", ")}`;
|
|
return result;
|
|
}
|
|
|
|
function compressShellOutput(content: string): string | null {
|
|
const hasAnsi = ANSI_RE.test(content);
|
|
const hasPrompt = SHELL_PROMPT_RE.test(content);
|
|
if (!hasAnsi && !hasPrompt) return null;
|
|
let cleaned = content.replace(ANSI_RE, "");
|
|
const lines = cleaned.split("\n");
|
|
const last50 = lines.slice(-50);
|
|
const deduped: string[] = [];
|
|
for (const line of last50) {
|
|
if (deduped.length === 0 || line !== deduped[deduped.length - 1]) {
|
|
deduped.push(line);
|
|
}
|
|
}
|
|
return deduped.join("\n");
|
|
}
|
|
|
|
function compressJson(content: string): string | null {
|
|
if (content.length <= 2000) return null;
|
|
if (!JSON_PREFIX_RE.test(content)) return null;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(content);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (Array.isArray(parsed)) {
|
|
const arr = parsed as unknown[];
|
|
if (arr.length <= 7) return content;
|
|
const head = arr.slice(0, 5);
|
|
const tail = arr.slice(-2);
|
|
return JSON.stringify({ type: "array", total: arr.length, first5: head, last2: tail }, null, 2);
|
|
}
|
|
if (typeof parsed === "object" && parsed !== null) {
|
|
const obj = parsed as Record<string, unknown>;
|
|
const keys = Object.keys(obj);
|
|
const summary: Record<string, unknown> = {};
|
|
for (const key of keys.slice(0, 20)) {
|
|
const val = obj[key];
|
|
if (typeof val === "object" && val !== null) {
|
|
summary[key] = `{…${Object.keys(val as Record<string, unknown>).length} keys}`;
|
|
} else {
|
|
summary[key] = val;
|
|
}
|
|
}
|
|
if (keys.length > 20) {
|
|
summary[`_remaining_${keys.length - 20}_keys`] = true;
|
|
}
|
|
return JSON.stringify(summary, null, 2);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function compressErrorMessage(content: string): string | null {
|
|
if (!hasErrorLikeOutput(content)) return null;
|
|
const lines = content.split("\n");
|
|
const errorLine = lines[0] || "";
|
|
const stackLines = lines.slice(1);
|
|
const head = stackLines.slice(0, 10);
|
|
const tail = stackLines.length > 10 ? stackLines.slice(-3) : [];
|
|
const middle = stackLines.length > 13 ? [`… [${stackLines.length - 13} frames elided] …`] : [];
|
|
const result = [errorLine, ...head, ...middle, ...tail].join("\n");
|
|
return result;
|
|
}
|
|
|
|
function estimateTokens(text: string): number {
|
|
return Math.ceil(text.length / 4);
|
|
}
|
|
|
|
/** Minimal shape of an Anthropic `tool_result` content block. */
|
|
export interface AnthropicToolResultBlock {
|
|
type: "tool_result";
|
|
tool_use_id?: string;
|
|
content?: string | Array<{ type?: string; text?: string; [key: string]: unknown }>;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export function isAnthropicToolResultBlock(value: unknown): value is AnthropicToolResultBlock {
|
|
return (
|
|
!!value &&
|
|
typeof value === "object" &&
|
|
(value as { type?: unknown }).type === "tool_result"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Compress the text inside an Anthropic-shape `tool_result` content block,
|
|
* reusing the same per-type strategies as OpenAI-shape tool messages. The
|
|
* `tool_use_id` and block type are preserved exactly; only the inner text is
|
|
* compressed. Returns the (possibly unchanged) block plus tokens saved.
|
|
*/
|
|
export function compressAnthropicToolResultBlock(
|
|
block: AnthropicToolResultBlock,
|
|
opts: ToolStrategiesConfig
|
|
): { block: AnthropicToolResultBlock; saved: number } {
|
|
const content = block.content;
|
|
|
|
if (typeof content === "string") {
|
|
if (!content || COMPRESSED_MARKER_RE.test(content)) return { block, saved: 0 };
|
|
const result = compressToolResult(content, opts);
|
|
if (result.strategy === "none" || result.saved <= 0) return { block, saved: 0 };
|
|
return { block: { ...block, content: result.compressed }, saved: result.saved };
|
|
}
|
|
|
|
if (Array.isArray(content)) {
|
|
let saved = 0;
|
|
let changed = false;
|
|
const nextContent = content.map((part) => {
|
|
if (
|
|
!part ||
|
|
typeof part !== "object" ||
|
|
part.type !== "text" ||
|
|
typeof part.text !== "string"
|
|
) {
|
|
return part;
|
|
}
|
|
const text = part.text;
|
|
if (!text || COMPRESSED_MARKER_RE.test(text)) return part;
|
|
const result = compressToolResult(text, opts);
|
|
if (result.strategy === "none" || result.saved <= 0) return part;
|
|
saved += result.saved;
|
|
changed = true;
|
|
return { ...part, text: result.compressed };
|
|
});
|
|
if (!changed) return { block, saved: 0 };
|
|
return { block: { ...block, content: nextContent }, saved };
|
|
}
|
|
|
|
return { block, saved: 0 };
|
|
}
|
|
|
|
export function compressToolResult(content: string, opts: ToolStrategiesConfig): CompressionResult {
|
|
if (opts.fileContent) {
|
|
const result = compressFileContent(content);
|
|
if (result !== null) {
|
|
return {
|
|
compressed: result,
|
|
strategy: "fileContent",
|
|
saved: estimateTokens(content) - estimateTokens(result),
|
|
};
|
|
}
|
|
}
|
|
|
|
if (opts.grepSearch) {
|
|
const result = compressGrepSearch(content);
|
|
if (result !== null) {
|
|
return {
|
|
compressed: result,
|
|
strategy: "grepSearch",
|
|
saved: estimateTokens(content) - estimateTokens(result),
|
|
};
|
|
}
|
|
}
|
|
|
|
if (opts.shellOutput) {
|
|
const result = compressShellOutput(content);
|
|
if (result !== null) {
|
|
return {
|
|
compressed: result,
|
|
strategy: "shellOutput",
|
|
saved: estimateTokens(content) - estimateTokens(result),
|
|
};
|
|
}
|
|
}
|
|
|
|
if (opts.json) {
|
|
const result = compressJson(content);
|
|
if (result !== null) {
|
|
return {
|
|
compressed: result,
|
|
strategy: "json",
|
|
saved: estimateTokens(content) - estimateTokens(result),
|
|
};
|
|
}
|
|
}
|
|
|
|
if (opts.errorMessage) {
|
|
const result = compressErrorMessage(content);
|
|
if (result !== null) {
|
|
return {
|
|
compressed: result,
|
|
strategy: "errorMessage",
|
|
saved: estimateTokens(content) - estimateTokens(result),
|
|
};
|
|
}
|
|
}
|
|
|
|
return { compressed: content, strategy: "none", saved: 0 };
|
|
}
|