mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 10:00:39 +00:00
Port truncation logic from coding-agent to mom
- Add truncate.ts with 2000 lines / 50KB limits - Update bash tool with tail truncation and temp file output - Update read tool with head truncation and offset hints - Remove redundant context history truncation (tools already provide actionable hints) fixes #155
This commit is contained in:
parent
de3fd172a9
commit
02c7f9ea51
5 changed files with 380 additions and 92 deletions
|
|
@ -18,6 +18,9 @@
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Port truncation logic from coding-agent: bash and read tools now use consistent 2000 lines OR 50KB limits with actionable notices
|
||||||
|
- Remove redundant context history truncation (tools already provide truncation with actionable hints)
|
||||||
|
|
||||||
- Message backfill on startup (#103)
|
- Message backfill on startup (#103)
|
||||||
- Fetches missed messages from Slack using `conversations.history` API when mom restarts
|
- Fetches missed messages from Slack using `conversations.history` API when mom restarts
|
||||||
- Backfills up to 3 pages (3000 messages) per channel since last logged timestamp
|
- Backfills up to 3 pages (3000 messages) per channel since last logged timestamp
|
||||||
|
|
|
||||||
|
|
@ -127,11 +127,7 @@ function getRecentMessages(channelDir: string, turnCount: number): string {
|
||||||
for (const msg of turn) {
|
for (const msg of turn) {
|
||||||
const date = (msg.date || "").substring(0, 19);
|
const date = (msg.date || "").substring(0, 19);
|
||||||
const user = msg.userName || msg.user || "";
|
const user = msg.userName || msg.user || "";
|
||||||
let text = msg.text || "";
|
const text = msg.text || "";
|
||||||
// Truncate bot messages (tool results can be huge)
|
|
||||||
if (msg.isBot) {
|
|
||||||
text = truncateForContext(text, 50000, 2000, msg.ts);
|
|
||||||
}
|
|
||||||
const attachments = (msg.attachments || []).map((a) => a.local).join(",");
|
const attachments = (msg.attachments || []).map((a) => a.local).join(",");
|
||||||
formatted.push(`${date}\t${user}\t${text}\t${attachments}`);
|
formatted.push(`${date}\t${user}\t${text}\t${attachments}`);
|
||||||
}
|
}
|
||||||
|
|
@ -140,43 +136,6 @@ function getRecentMessages(channelDir: string, turnCount: number): string {
|
||||||
return formatted.join("\n");
|
return formatted.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncate text to maxChars or maxLines, whichever comes first.
|
|
||||||
* Adds a note with stats and instructions if truncation occurred.
|
|
||||||
*/
|
|
||||||
function truncateForContext(text: string, maxChars: number, maxLines: number, ts?: string): string {
|
|
||||||
const lines = text.split("\n");
|
|
||||||
const originalLines = lines.length;
|
|
||||||
const originalChars = text.length;
|
|
||||||
let truncated = false;
|
|
||||||
let result = text;
|
|
||||||
|
|
||||||
// Check line limit first
|
|
||||||
if (lines.length > maxLines) {
|
|
||||||
result = lines.slice(0, maxLines).join("\n");
|
|
||||||
truncated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check char limit
|
|
||||||
if (result.length > maxChars) {
|
|
||||||
result = result.substring(0, maxChars);
|
|
||||||
truncated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (truncated) {
|
|
||||||
const remainingLines = originalLines - result.split("\n").length;
|
|
||||||
const remainingChars = originalChars - result.length;
|
|
||||||
result += `\n[... truncated ${remainingLines} more lines, ${remainingChars} more chars. `;
|
|
||||||
if (ts) {
|
|
||||||
result += `To get full content: jq -r 'select(.ts=="${ts}") | .text' log.jsonl > /tmp/msg.txt, then read /tmp/msg.txt in segments]`;
|
|
||||||
} else {
|
|
||||||
result += `Search log.jsonl for full content]`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMemory(channelDir: string): string {
|
function getMemory(channelDir: string): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,19 @@
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { createWriteStream } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import type { AgentTool } from "@mariozechner/pi-ai";
|
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import type { Executor } from "../sandbox.js";
|
import type { Executor } from "../sandbox.js";
|
||||||
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique temp file path for bash output
|
||||||
|
*/
|
||||||
|
function getTempFilePath(): string {
|
||||||
|
const id = randomBytes(8).toString("hex");
|
||||||
|
return join(tmpdir(), `mom-bash-${id}.log`);
|
||||||
|
}
|
||||||
|
|
||||||
const bashSchema = Type.Object({
|
const bashSchema = Type.Object({
|
||||||
label: Type.String({ description: "Brief description of what this command does (shown to user)" }),
|
label: Type.String({ description: "Brief description of what this command does (shown to user)" }),
|
||||||
|
|
@ -8,18 +21,26 @@ const bashSchema = Type.Object({
|
||||||
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
|
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface BashToolDetails {
|
||||||
|
truncation?: TruncationResult;
|
||||||
|
fullOutputPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function createBashTool(executor: Executor): AgentTool<typeof bashSchema> {
|
export function createBashTool(executor: Executor): AgentTool<typeof bashSchema> {
|
||||||
return {
|
return {
|
||||||
name: "bash",
|
name: "bash",
|
||||||
label: "bash",
|
label: "bash",
|
||||||
description:
|
description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
|
||||||
"Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
|
|
||||||
parameters: bashSchema,
|
parameters: bashSchema,
|
||||||
execute: async (
|
execute: async (
|
||||||
_toolCallId: string,
|
_toolCallId: string,
|
||||||
{ command, timeout }: { label: string; command: string; timeout?: number },
|
{ command, timeout }: { label: string; command: string; timeout?: number },
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) => {
|
) => {
|
||||||
|
// Track output for potential temp file writing
|
||||||
|
let tempFilePath: string | undefined;
|
||||||
|
let tempFileStream: ReturnType<typeof createWriteStream> | undefined;
|
||||||
|
|
||||||
const result = await executor.exec(command, { timeout, signal });
|
const result = await executor.exec(command, { timeout, signal });
|
||||||
let output = "";
|
let output = "";
|
||||||
if (result.stdout) output += result.stdout;
|
if (result.stdout) output += result.stdout;
|
||||||
|
|
@ -28,11 +49,49 @@ export function createBashTool(executor: Executor): AgentTool<typeof bashSchema>
|
||||||
output += result.stderr;
|
output += result.stderr;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.code !== 0) {
|
const totalBytes = Buffer.byteLength(output, "utf-8");
|
||||||
throw new Error(`${output}\n\nCommand exited with code ${result.code}`.trim());
|
|
||||||
|
// Write to temp file if output exceeds limit
|
||||||
|
if (totalBytes > DEFAULT_MAX_BYTES) {
|
||||||
|
tempFilePath = getTempFilePath();
|
||||||
|
tempFileStream = createWriteStream(tempFilePath);
|
||||||
|
tempFileStream.write(output);
|
||||||
|
tempFileStream.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { content: [{ type: "text", text: output || "(no output)" }], details: undefined };
|
// Apply tail truncation
|
||||||
|
const truncation = truncateTail(output);
|
||||||
|
let outputText = truncation.content || "(no output)";
|
||||||
|
|
||||||
|
// Build details with truncation info
|
||||||
|
let details: BashToolDetails | undefined;
|
||||||
|
|
||||||
|
if (truncation.truncated) {
|
||||||
|
details = {
|
||||||
|
truncation,
|
||||||
|
fullOutputPath: tempFilePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build actionable notice
|
||||||
|
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
||||||
|
const endLine = truncation.totalLines;
|
||||||
|
|
||||||
|
if (truncation.lastLinePartial) {
|
||||||
|
// Edge case: last line alone > 50KB
|
||||||
|
const lastLineSize = formatSize(Buffer.byteLength(output.split("\n").pop() || "", "utf-8"));
|
||||||
|
outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;
|
||||||
|
} else if (truncation.truncatedBy === "lines") {
|
||||||
|
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`;
|
||||||
|
} else {
|
||||||
|
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(`${outputText}\n\nCommand exited with code ${result.code}`.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: [{ type: "text", text: outputText }], details };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { AgentTool, ImageContent, TextContent } from "@mariozechner/pi-ai";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { extname } from "path";
|
import { extname } from "path";
|
||||||
import type { Executor } from "../sandbox.js";
|
import type { Executor } from "../sandbox.js";
|
||||||
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map of file extensions to MIME types for common image formats
|
* Map of file extensions to MIME types for common image formats
|
||||||
|
|
@ -29,21 +30,21 @@ const readSchema = Type.Object({
|
||||||
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
|
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
|
||||||
});
|
});
|
||||||
|
|
||||||
const MAX_LINES = 2000;
|
interface ReadToolDetails {
|
||||||
const MAX_LINE_LENGTH = 2000;
|
truncation?: TruncationResult;
|
||||||
|
}
|
||||||
|
|
||||||
export function createReadTool(executor: Executor): AgentTool<typeof readSchema> {
|
export function createReadTool(executor: Executor): AgentTool<typeof readSchema> {
|
||||||
return {
|
return {
|
||||||
name: "read",
|
name: "read",
|
||||||
label: "read",
|
label: "read",
|
||||||
description:
|
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`,
|
||||||
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit for large files.",
|
|
||||||
parameters: readSchema,
|
parameters: readSchema,
|
||||||
execute: async (
|
execute: async (
|
||||||
_toolCallId: string,
|
_toolCallId: string,
|
||||||
{ path, offset, limit }: { label: string; path: string; offset?: number; limit?: number },
|
{ path, offset, limit }: { label: string; path: string; offset?: number; limit?: number },
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) => {
|
): Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }> => {
|
||||||
const mimeType = isImageFile(path);
|
const mimeType = isImageFile(path);
|
||||||
|
|
||||||
if (mimeType) {
|
if (mimeType) {
|
||||||
|
|
@ -58,65 +59,95 @@ export function createReadTool(executor: Executor): AgentTool<typeof readSchema>
|
||||||
content: [
|
content: [
|
||||||
{ type: "text", text: `Read image file [${mimeType}]` },
|
{ type: "text", text: `Read image file [${mimeType}]` },
|
||||||
{ type: "image", data: base64, mimeType },
|
{ type: "image", data: base64, mimeType },
|
||||||
] as (TextContent | ImageContent)[],
|
],
|
||||||
details: undefined,
|
details: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read as text using cat with offset/limit via sed/head/tail
|
// Get total line count first
|
||||||
let cmd: string;
|
const countResult = await executor.exec(`wc -l < ${shellEscape(path)}`, { signal });
|
||||||
const startLine = offset ? Math.max(1, offset) : 1;
|
if (countResult.code !== 0) {
|
||||||
const maxLines = limit || MAX_LINES;
|
throw new Error(countResult.stderr || `Failed to read file: ${path}`);
|
||||||
|
}
|
||||||
|
const totalFileLines = Number.parseInt(countResult.stdout.trim(), 10) + 1; // wc -l counts newlines, not lines
|
||||||
|
|
||||||
if (startLine === 1) {
|
// Apply offset if specified (1-indexed)
|
||||||
cmd = `head -n ${maxLines} ${shellEscape(path)}`;
|
const startLine = offset ? Math.max(1, offset) : 1;
|
||||||
} else {
|
const startLineDisplay = startLine;
|
||||||
cmd = `sed -n '${startLine},${startLine + maxLines - 1}p' ${shellEscape(path)}`;
|
|
||||||
|
// Check if offset is out of bounds
|
||||||
|
if (startLine > totalFileLines) {
|
||||||
|
throw new Error(`Offset ${offset} is beyond end of file (${totalFileLines} lines total)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also get total line count
|
// Read content with offset
|
||||||
const countResult = await executor.exec(`wc -l < ${shellEscape(path)}`, { signal });
|
let cmd: string;
|
||||||
const totalLines = Number.parseInt(countResult.stdout.trim(), 10) || 0;
|
if (startLine === 1) {
|
||||||
|
cmd = `cat ${shellEscape(path)}`;
|
||||||
|
} else {
|
||||||
|
cmd = `tail -n +${startLine} ${shellEscape(path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await executor.exec(cmd, { signal });
|
const result = await executor.exec(cmd, { signal });
|
||||||
if (result.code !== 0) {
|
if (result.code !== 0) {
|
||||||
throw new Error(result.stderr || `Failed to read file: ${path}`);
|
throw new Error(result.stderr || `Failed to read file: ${path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = result.stdout.split("\n");
|
let selectedContent = result.stdout;
|
||||||
|
let userLimitedLines: number | undefined;
|
||||||
|
|
||||||
// Truncate long lines
|
// Apply user limit if specified
|
||||||
let hadTruncatedLines = false;
|
if (limit !== undefined) {
|
||||||
const formattedLines = lines.map((line) => {
|
const lines = selectedContent.split("\n");
|
||||||
if (line.length > MAX_LINE_LENGTH) {
|
const endLine = Math.min(limit, lines.length);
|
||||||
hadTruncatedLines = true;
|
selectedContent = lines.slice(0, endLine).join("\n");
|
||||||
return line.slice(0, MAX_LINE_LENGTH);
|
userLimitedLines = endLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply truncation (respects both line and byte limits)
|
||||||
|
const truncation = truncateHead(selectedContent);
|
||||||
|
|
||||||
|
let outputText: string;
|
||||||
|
let details: ReadToolDetails | undefined;
|
||||||
|
|
||||||
|
if (truncation.firstLineExceedsLimit) {
|
||||||
|
// First line at offset exceeds 50KB - tell model to use bash
|
||||||
|
const firstLineSize = formatSize(Buffer.byteLength(selectedContent.split("\n")[0], "utf-8"));
|
||||||
|
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
|
||||||
|
details = { truncation };
|
||||||
|
} else if (truncation.truncated) {
|
||||||
|
// Truncation occurred - build actionable notice
|
||||||
|
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
|
||||||
|
const nextOffset = endLineDisplay + 1;
|
||||||
|
|
||||||
|
outputText = truncation.content;
|
||||||
|
|
||||||
|
if (truncation.truncatedBy === "lines") {
|
||||||
|
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
|
||||||
|
} else {
|
||||||
|
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue]`;
|
||||||
}
|
}
|
||||||
return line;
|
details = { truncation };
|
||||||
});
|
} else if (userLimitedLines !== undefined) {
|
||||||
|
// User specified limit, check if there's more content
|
||||||
|
const linesFromStart = startLine - 1 + userLimitedLines;
|
||||||
|
if (linesFromStart < totalFileLines) {
|
||||||
|
const remaining = totalFileLines - linesFromStart;
|
||||||
|
const nextOffset = startLine + userLimitedLines;
|
||||||
|
|
||||||
let outputText = formattedLines.join("\n");
|
outputText = truncation.content;
|
||||||
|
outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`;
|
||||||
// Add notices
|
} else {
|
||||||
const notices: string[] = [];
|
outputText = truncation.content;
|
||||||
const endLine = startLine + lines.length - 1;
|
}
|
||||||
|
} else {
|
||||||
if (hadTruncatedLines) {
|
// No truncation, no user limit exceeded
|
||||||
notices.push(`Some lines were truncated to ${MAX_LINE_LENGTH} characters for display`);
|
outputText = truncation.content;
|
||||||
}
|
|
||||||
|
|
||||||
if (endLine < totalLines) {
|
|
||||||
const remaining = totalLines - endLine;
|
|
||||||
notices.push(`${remaining} more lines not shown. Use offset=${endLine + 1} to continue reading`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notices.length > 0) {
|
|
||||||
outputText += `\n\n... (${notices.join(". ")})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: outputText }] as (TextContent | ImageContent)[],
|
content: [{ type: "text", text: outputText }],
|
||||||
details: undefined,
|
details,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
236
packages/mom/src/tools/truncate.ts
Normal file
236
packages/mom/src/tools/truncate.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
/**
|
||||||
|
* Shared truncation utilities for tool outputs.
|
||||||
|
*
|
||||||
|
* Truncation is based on two independent limits - whichever is hit first wins:
|
||||||
|
* - Line limit (default: 2000 lines)
|
||||||
|
* - Byte limit (default: 50KB)
|
||||||
|
*
|
||||||
|
* Never returns partial lines (except bash tail truncation edge case).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_MAX_LINES = 2000;
|
||||||
|
export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
|
||||||
|
|
||||||
|
export interface TruncationResult {
|
||||||
|
/** The truncated content */
|
||||||
|
content: string;
|
||||||
|
/** Whether truncation occurred */
|
||||||
|
truncated: boolean;
|
||||||
|
/** Which limit was hit: "lines", "bytes", or null if not truncated */
|
||||||
|
truncatedBy: "lines" | "bytes" | null;
|
||||||
|
/** Total number of lines in the original content */
|
||||||
|
totalLines: number;
|
||||||
|
/** Total number of bytes in the original content */
|
||||||
|
totalBytes: number;
|
||||||
|
/** Number of complete lines in the truncated output */
|
||||||
|
outputLines: number;
|
||||||
|
/** Number of bytes in the truncated output */
|
||||||
|
outputBytes: number;
|
||||||
|
/** Whether the last line was partially truncated (only for tail truncation edge case) */
|
||||||
|
lastLinePartial: boolean;
|
||||||
|
/** Whether the first line exceeded the byte limit (for head truncation) */
|
||||||
|
firstLineExceedsLimit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TruncationOptions {
|
||||||
|
/** Maximum number of lines (default: 2000) */
|
||||||
|
maxLines?: number;
|
||||||
|
/** Maximum number of bytes (default: 50KB) */
|
||||||
|
maxBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes as human-readable size.
|
||||||
|
*/
|
||||||
|
export function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) {
|
||||||
|
return `${bytes}B`;
|
||||||
|
} else if (bytes < 1024 * 1024) {
|
||||||
|
return `${(bytes / 1024).toFixed(1)}KB`;
|
||||||
|
} else {
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate content from the head (keep first N lines/bytes).
|
||||||
|
* Suitable for file reads where you want to see the beginning.
|
||||||
|
*
|
||||||
|
* Never returns partial lines. If first line exceeds byte limit,
|
||||||
|
* returns empty content with firstLineExceedsLimit=true.
|
||||||
|
*/
|
||||||
|
export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult {
|
||||||
|
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
||||||
|
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||||
|
|
||||||
|
const totalBytes = Buffer.byteLength(content, "utf-8");
|
||||||
|
const lines = content.split("\n");
|
||||||
|
const totalLines = lines.length;
|
||||||
|
|
||||||
|
// Check if no truncation needed
|
||||||
|
if (totalLines <= maxLines && totalBytes <= maxBytes) {
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
truncated: false,
|
||||||
|
truncatedBy: null,
|
||||||
|
totalLines,
|
||||||
|
totalBytes,
|
||||||
|
outputLines: totalLines,
|
||||||
|
outputBytes: totalBytes,
|
||||||
|
lastLinePartial: false,
|
||||||
|
firstLineExceedsLimit: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first line alone exceeds byte limit
|
||||||
|
const firstLineBytes = Buffer.byteLength(lines[0], "utf-8");
|
||||||
|
if (firstLineBytes > maxBytes) {
|
||||||
|
return {
|
||||||
|
content: "",
|
||||||
|
truncated: true,
|
||||||
|
truncatedBy: "bytes",
|
||||||
|
totalLines,
|
||||||
|
totalBytes,
|
||||||
|
outputLines: 0,
|
||||||
|
outputBytes: 0,
|
||||||
|
lastLinePartial: false,
|
||||||
|
firstLineExceedsLimit: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect complete lines that fit
|
||||||
|
const outputLinesArr: string[] = [];
|
||||||
|
let outputBytesCount = 0;
|
||||||
|
let truncatedBy: "lines" | "bytes" = "lines";
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length && i < maxLines; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline
|
||||||
|
|
||||||
|
if (outputBytesCount + lineBytes > maxBytes) {
|
||||||
|
truncatedBy = "bytes";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
outputLinesArr.push(line);
|
||||||
|
outputBytesCount += lineBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we exited due to line limit
|
||||||
|
if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
|
||||||
|
truncatedBy = "lines";
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputContent = outputLinesArr.join("\n");
|
||||||
|
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: outputContent,
|
||||||
|
truncated: true,
|
||||||
|
truncatedBy,
|
||||||
|
totalLines,
|
||||||
|
totalBytes,
|
||||||
|
outputLines: outputLinesArr.length,
|
||||||
|
outputBytes: finalOutputBytes,
|
||||||
|
lastLinePartial: false,
|
||||||
|
firstLineExceedsLimit: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate content from the tail (keep last N lines/bytes).
|
||||||
|
* Suitable for bash output where you want to see the end (errors, final results).
|
||||||
|
*
|
||||||
|
* May return partial first line if the last line of original content exceeds byte limit.
|
||||||
|
*/
|
||||||
|
export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult {
|
||||||
|
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
||||||
|
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||||
|
|
||||||
|
const totalBytes = Buffer.byteLength(content, "utf-8");
|
||||||
|
const lines = content.split("\n");
|
||||||
|
const totalLines = lines.length;
|
||||||
|
|
||||||
|
// Check if no truncation needed
|
||||||
|
if (totalLines <= maxLines && totalBytes <= maxBytes) {
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
truncated: false,
|
||||||
|
truncatedBy: null,
|
||||||
|
totalLines,
|
||||||
|
totalBytes,
|
||||||
|
outputLines: totalLines,
|
||||||
|
outputBytes: totalBytes,
|
||||||
|
lastLinePartial: false,
|
||||||
|
firstLineExceedsLimit: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work backwards from the end
|
||||||
|
const outputLinesArr: string[] = [];
|
||||||
|
let outputBytesCount = 0;
|
||||||
|
let truncatedBy: "lines" | "bytes" = "lines";
|
||||||
|
let lastLinePartial = false;
|
||||||
|
|
||||||
|
for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
|
||||||
|
const line = lines[i];
|
||||||
|
const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
|
||||||
|
|
||||||
|
if (outputBytesCount + lineBytes > maxBytes) {
|
||||||
|
truncatedBy = "bytes";
|
||||||
|
// Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes,
|
||||||
|
// take the end of the line (partial)
|
||||||
|
if (outputLinesArr.length === 0) {
|
||||||
|
const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
|
||||||
|
outputLinesArr.unshift(truncatedLine);
|
||||||
|
outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
|
||||||
|
lastLinePartial = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
outputLinesArr.unshift(line);
|
||||||
|
outputBytesCount += lineBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we exited due to line limit
|
||||||
|
if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
|
||||||
|
truncatedBy = "lines";
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputContent = outputLinesArr.join("\n");
|
||||||
|
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: outputContent,
|
||||||
|
truncated: true,
|
||||||
|
truncatedBy,
|
||||||
|
totalLines,
|
||||||
|
totalBytes,
|
||||||
|
outputLines: outputLinesArr.length,
|
||||||
|
outputBytes: finalOutputBytes,
|
||||||
|
lastLinePartial,
|
||||||
|
firstLineExceedsLimit: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate a string to fit within a byte limit (from the end).
|
||||||
|
* Handles multi-byte UTF-8 characters correctly.
|
||||||
|
*/
|
||||||
|
function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
|
||||||
|
const buf = Buffer.from(str, "utf-8");
|
||||||
|
if (buf.length <= maxBytes) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start from the end, skip maxBytes back
|
||||||
|
let start = buf.length - maxBytes;
|
||||||
|
|
||||||
|
// Find a valid UTF-8 boundary (start of a character)
|
||||||
|
while (start < buf.length && (buf[start] & 0xc0) === 0x80) {
|
||||||
|
start++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.slice(start).toString("utf-8");
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue