From ce28fd37fcc3ed5ae419b762b387f65e08347365 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Wed, 7 Jan 2026 13:52:51 -0700 Subject: [PATCH 1/3] feat: integrate file persistence into centralized truncation system Refactors the file persistence feature to work with the new centralized truncation architecture in upstream. Instead of modifying each tool individually, this integrates file persistence directly into the Truncate namespace. When tool output exceeds 50KB/2000 lines, the full output is saved to disk and the model receives a head+tail preview with instructions for exploring the data using Read/Grep/jq tools. Changes: - truncation.ts: Add outputWithPersistence() function with: - Head (67%) + tail (33%) preview respecting caller's maxLines - Preview byte cap respecting min(caller's maxBytes, 20KB) - JSON detection (.json extension + jq hint) - 10MB hard cap on persisted files (using Buffer for both detection AND truncation to handle multibyte chars correctly) - Tool name sanitization (64 char limit, safe chars only) - Path traversal protection (sessionID validation + resolve check) - cleanupSessionFiles() for session deletion - tool.ts: Update Tool.define() wrapper to use outputWithPersistence - registry.ts: Update fromPlugin() to use outputWithPersistence - session/index.ts: Call Truncate.cleanupSessionFiles on session delete Benefits over previous per-tool approach: - Single point of change - all tools automatically get file persistence - Aligns with upstream's centralized truncation architecture - No duplicate truncation logic across multiple files - Cleaner integration with existing session lifecycle --- packages/opencode/src/session/index.ts | 2 + packages/opencode/src/session/truncation.ts | 158 ++++++++++++++++++++ packages/opencode/src/tool/registry.ts | 13 +- packages/opencode/src/tool/tool.ts | 7 +- 4 files changed, 176 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 0776590d6a9..c101742a722 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -16,6 +16,7 @@ import { SessionPrompt } from "./prompt" import { fn } from "@/util/fn" import { Command } from "../command" import { Snapshot } from "@/snapshot" +import { Truncate } from "./truncation" import type { Provider } from "@/provider/provider" import { PermissionNext } from "@/permission/next" @@ -318,6 +319,7 @@ export namespace Session { } await Storage.remove(msg) } + await Truncate.cleanupSessionFiles(sessionID) await Storage.remove(["session", project.id, sessionID]) Bus.publish(Event.Deleted, { info: session, diff --git a/packages/opencode/src/session/truncation.ts b/packages/opencode/src/session/truncation.ts index 15177a55a65..84a8a832546 100644 --- a/packages/opencode/src/session/truncation.ts +++ b/packages/opencode/src/session/truncation.ts @@ -1,10 +1,19 @@ +import path from "path" +import fs from "fs/promises" +import { ulid } from "ulid" +import { Global } from "../global" + export namespace Truncate { export const MAX_LINES = 2000 export const MAX_BYTES = 50 * 1024 + export const MAX_PERSIST_BYTES = 10 * 1024 * 1024 // 10MB hard limit for persisted files + export const PREVIEW_HEAD_LINES = 100 + export const PREVIEW_TAIL_LINES = 50 export interface Result { content: string truncated: boolean + filePath?: string } export interface Options { @@ -13,6 +22,108 @@ export namespace Truncate { direction?: "head" | "tail" } + export interface PersistOptions extends Options { + sessionID: string + toolName: string + callID?: string + } + + function sanitizeToolName(name: string): string { + return name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 64) + } + + function isLikelyJson(text: string): boolean { + const trimmed = text.trimStart() + return trimmed.startsWith("{") || trimmed.startsWith("[") + } + + const MAX_PREVIEW_BYTES = 20 * 1024 // 20KB default max for preview content + + interface PreviewOptions { + maxBytes?: number + maxLines?: number + } + + function getPreviewHeadTail( + lines: string[], + totalBytes: number, + totalLines: number, + options: PreviewOptions = {}, + ): string { + const maxPreviewBytes = Math.min(options.maxBytes ?? MAX_PREVIEW_BYTES, MAX_PREVIEW_BYTES) + const maxPreviewLines = options.maxLines ?? PREVIEW_HEAD_LINES + PREVIEW_TAIL_LINES + + // Calculate how many lines we can show, respecting maxLines + const headCount = Math.min(PREVIEW_HEAD_LINES, Math.floor(maxPreviewLines * 0.67)) + const tailCount = Math.min(PREVIEW_TAIL_LINES, maxPreviewLines - headCount) + + const headLines = lines.slice(0, headCount) + const tailLines = totalLines > headCount + tailCount ? lines.slice(-tailCount) : [] + const omittedLines = totalLines - headCount - tailLines.length + + let preview = headLines.join("\n") + if (omittedLines > 0 && tailLines.length > 0) { + preview += `\n\n... ${omittedLines.toLocaleString()} lines omitted (${totalBytes.toLocaleString()} bytes total) ...\n\n` + preview += tailLines.join("\n") + } else if (omittedLines > 0) { + preview += `\n\n... ${omittedLines.toLocaleString()} lines omitted (${totalBytes.toLocaleString()} bytes total) ...` + } + + // Ensure preview itself doesn't exceed byte limit (handles few-lines-but-large-bytes case) + const previewBytes = Buffer.byteLength(preview, "utf-8") + if (previewBytes > maxPreviewBytes) { + const headBudget = Math.floor(maxPreviewBytes * 0.6) // 60% for head + const tailBudget = Math.floor(maxPreviewBytes * 0.3) // 30% for tail + const headBuf = Buffer.from(preview, "utf-8").subarray(0, headBudget) + const tailBuf = Buffer.from(preview, "utf-8").subarray(-tailBudget) + const truncatedHead = headBuf.toString("utf-8") + const truncatedTail = tailBuf.toString("utf-8") + preview = `${truncatedHead}\n\n... preview truncated (${totalBytes.toLocaleString()} bytes total) ...\n\n${truncatedTail}` + } + + return preview + } + + function isValidSessionID(sessionID: string): boolean { + return /^ses_[a-zA-Z0-9_-]+$/.test(sessionID) && !sessionID.includes("..") + } + + async function saveToFile( + content: string, + sessionID: string, + toolName: string, + callID?: string, + ): Promise { + if (!isValidSessionID(sessionID)) { + throw new Error(`Invalid sessionID format: ${sessionID}`) + } + + const sanitizedName = sanitizeToolName(toolName) + const extension = isLikelyJson(content) ? ".json" : ".txt" + const callPart = callID ? `-${callID.slice(-8)}` : "" + const filename = `${sanitizedName}${callPart}-${ulid()}${extension}` + const baseDir = path.join(Global.Path.data, "storage", "tool_results") + const dir = path.join(baseDir, sessionID) + const filePath = path.join(dir, filename) + + const resolvedPath = path.resolve(filePath) + const resolvedBase = path.resolve(baseDir) + if (!resolvedPath.startsWith(resolvedBase + path.sep)) { + throw new Error(`Path traversal detected: ${filePath}`) + } + + await fs.mkdir(dir, { recursive: true }) + + const contentBytes = Buffer.byteLength(content, "utf-8") + let contentToSave = content + if (contentBytes > MAX_PERSIST_BYTES) { + const buf = Buffer.from(content, "utf-8").subarray(0, MAX_PERSIST_BYTES) + contentToSave = buf.toString("utf-8") + } + await fs.writeFile(filePath, contentToSave, "utf-8") + return filePath + } + export function output(text: string, options: Options = {}): Result { const maxLines = options.maxLines ?? MAX_LINES const maxBytes = options.maxBytes ?? MAX_BYTES @@ -57,4 +168,51 @@ export namespace Truncate { const unit = hitBytes ? "chars" : "lines" return { content: `...${removed} ${unit} truncated...\n\n${out.join("\n")}`, truncated: true } } + + export async function outputWithPersistence( + text: string, + options: PersistOptions, + ): Promise { + const maxLines = options.maxLines ?? MAX_LINES + const maxBytes = options.maxBytes ?? MAX_BYTES + const lines = text.split("\n") + const totalBytes = Buffer.byteLength(text, "utf-8") + + if (lines.length <= maxLines && totalBytes <= maxBytes) { + return { content: text, truncated: false } + } + + const filePath = await saveToFile(text, options.sessionID, options.toolName, options.callID) + const preview = getPreviewHeadTail(lines, totalBytes, lines.length, { + maxBytes: maxBytes, + maxLines: maxLines, + }) + const isJson = isLikelyJson(text) + const wasCapped = totalBytes > MAX_PERSIST_BYTES + + let instructions = `\n\n +Full output (${totalBytes.toLocaleString()} bytes, ${lines.length.toLocaleString()} lines) saved to: ${filePath}${wasCapped ? `\nNote: Output was capped at ${MAX_PERSIST_BYTES.toLocaleString()} bytes due to size limits.` : ""} +To explore: use Read tool with offset/limit parameters, or Grep to search for specific content.${isJson ? "\nThis appears to be JSON - you can use bash with jq for structured queries." : ""} +` + + return { + content: preview + instructions, + truncated: true, + filePath, + } + } + + export async function cleanupSessionFiles(sessionID: string): Promise { + if (!isValidSessionID(sessionID)) { + return + } + const baseDir = path.join(Global.Path.data, "storage", "tool_results") + const dir = path.join(baseDir, sessionID) + const resolvedDir = path.resolve(dir) + const resolvedBase = path.resolve(baseDir) + if (!resolvedDir.startsWith(resolvedBase + path.sep)) { + return + } + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}) + } } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index bca6626db70..3207e6d1ef3 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -65,11 +65,18 @@ export namespace ToolRegistry { description: def.description, execute: async (args, ctx) => { const result = await def.execute(args as any, ctx) - const out = Truncate.output(result) + const out = await Truncate.outputWithPersistence(result, { + sessionID: ctx.sessionID, + toolName: id, + callID: ctx.callID, + }) return { title: "", - output: out.truncated ? out.content : result, - metadata: { truncated: out.truncated }, + output: out.content, + metadata: { + truncated: out.truncated, + ...(out.filePath && { truncatedFilePath: out.filePath }), + }, } }, }), diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 060da0ae763..aef62ba7eef 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -66,13 +66,18 @@ export namespace Tool { ) } const result = await execute(args, ctx) - const truncated = Truncate.output(result.output) + const truncated = await Truncate.outputWithPersistence(result.output, { + sessionID: ctx.sessionID, + toolName: id, + callID: ctx.callID, + }) return { ...result, output: truncated.content, metadata: { ...result.metadata, truncated: truncated.truncated, + ...(truncated.filePath && { truncatedFilePath: truncated.filePath }), }, } } From 95d5a568f28ef8e743c21e70841d621f2b4f83aa Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Wed, 7 Jan 2026 14:32:45 -0700 Subject: [PATCH 2/3] fix: add file persistence for MCP tools MCP tools have a separate execution path in prompt.ts that wasn't covered by the centralized truncation changes. This adds Truncate.outputWithPersistence() to the MCP tool wrapper so that large outputs from MCP tools (like firecrawl) are also saved to disk instead of overflowing the context window. Also improves the truncation notice to be more explicit that the preview is INCOMPLETE and the model MUST read the file for accurate results - prevents models from answering based on partial data. --- packages/opencode/src/session/prompt.ts | 16 ++++++++++++++-- packages/opencode/src/session/truncation.ts | 3 ++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f891612272c..15b13bcf34b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -7,6 +7,7 @@ import { MessageV2 } from "./message-v2" import { Log } from "../util/log" import { SessionRevert } from "./revert" import { Session } from "." +import { Truncate } from "./truncation" import { Agent } from "../agent/agent" import { Provider } from "../provider/provider" import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions } from "ai" @@ -781,10 +782,21 @@ export namespace SessionPrompt { // Add support for other types if needed } + const rawOutput = textParts.join("\n\n") + const truncated = await Truncate.outputWithPersistence(rawOutput, { + sessionID: ctx.sessionID, + toolName: key, + callID: opts.toolCallId, + }) + return { title: "", - metadata: result.metadata ?? {}, - output: textParts.join("\n\n"), + metadata: { + ...result.metadata, + truncated: truncated.truncated, + ...(truncated.filePath && { truncatedFilePath: truncated.filePath }), + }, + output: truncated.content, attachments, content: result.content, // directly return content to preserve ordering when outputting to model } diff --git a/packages/opencode/src/session/truncation.ts b/packages/opencode/src/session/truncation.ts index 84a8a832546..0c01fe3e228 100644 --- a/packages/opencode/src/session/truncation.ts +++ b/packages/opencode/src/session/truncation.ts @@ -191,8 +191,9 @@ export namespace Truncate { const wasCapped = totalBytes > MAX_PERSIST_BYTES let instructions = `\n\n +WARNING: Output was truncated. The preview above is INCOMPLETE and may be missing critical data. Full output (${totalBytes.toLocaleString()} bytes, ${lines.length.toLocaleString()} lines) saved to: ${filePath}${wasCapped ? `\nNote: Output was capped at ${MAX_PERSIST_BYTES.toLocaleString()} bytes due to size limits.` : ""} -To explore: use Read tool with offset/limit parameters, or Grep to search for specific content.${isJson ? "\nThis appears to be JSON - you can use bash with jq for structured queries." : ""} +You MUST read the file to get complete/accurate results. Use Read tool with offset/limit, Grep to search, or bash with jq for JSON. ` return { From 5e0aca13c5aae0f241f5b9d2ed16c9ceb17ec9a8 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Wed, 7 Jan 2026 14:39:35 -0700 Subject: [PATCH 3/3] feat(tui): show [truncated] indicator on tool calls When a tool output is truncated and saved to file, the TUI now shows a [truncated] indicator in the tool call line so users can see at a glance that the output was truncated. Shows in warning color on both InlineTool and BlockTool displays. --- .../src/cli/cmd/tui/routes/session/index.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index aa331ca0f0d..c8eb062cdfa 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1424,6 +1424,11 @@ function InlineTool(props: { const ctx = use() const sync = useSync() + const truncated = createMemo(() => { + if (props.part.state.status !== "completed") return false + return props.part.state.metadata?.truncated === true + }) + const permission = createMemo(() => { const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID if (!callID) return false @@ -1470,6 +1475,9 @@ function InlineTool(props: { ~ {props.pending}} when={props.complete}> {props.icon} {props.children} + + [truncated] + @@ -1484,6 +1492,10 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () = const renderer = useRenderer() const [hover, setHover] = createSignal(false) const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) + const truncated = createMemo(() => { + if (props.part?.state.status !== "completed") return false + return props.part.state.metadata?.truncated === true + }) return ( {props.title} + + [truncated] + {props.children}