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}
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/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 15177a55a65..0c01fe3e228 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,52 @@ 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
+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.` : ""}
+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 {
+ 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 }),
},
}
}