diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index cc8942c2ae..df641c07ff 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -4,6 +4,7 @@ import { Provider } from "../provider/provider" import { generateObject, type ModelMessage } from "ai" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" +import { Truncate } from "../session/truncation" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" @@ -46,7 +47,10 @@ export namespace Agent { const defaults = PermissionNext.fromConfig({ "*": "allow", doom_loop: "ask", - external_directory: "ask", + external_directory: { + "*": "ask", + [Truncate.DIR]: "allow", + }, // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index ad6e22e1be..b1c310e4d6 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -9,6 +9,7 @@ export namespace Identifier { user: "usr", part: "prt", pty: "pty", + tool: "tool", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/session/truncation.ts b/packages/opencode/src/session/truncation.ts index 15177a55a6..d1bb933403 100644 --- a/packages/opencode/src/session/truncation.ts +++ b/packages/opencode/src/session/truncation.ts @@ -1,10 +1,20 @@ +import fs from "fs/promises" +import path from "path" +import { Global } from "../global" +import { Identifier } from "../id/id" +import { iife } from "../util/iife" +import { lazy } from "../util/lazy" + export namespace Truncate { export const MAX_LINES = 2000 export const MAX_BYTES = 50 * 1024 + export const DIR = path.join(Global.Path.data, "tool-output") + const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days export interface Result { content: string truncated: boolean + outputPath?: string } export interface Options { @@ -13,7 +23,22 @@ export namespace Truncate { direction?: "head" | "tail" } - export function output(text: string, options: Options = {}): Result { + const init = lazy(async () => { + const cutoff = Date.now() - RETENTION_MS + const entries = await fs.readdir(DIR).catch(() => [] as string[]) + for (const entry of entries) { + if (!entry.startsWith("tool_")) continue + const timestamp = iife(() => { + const hex = entry.slice(5, 17) + const now = BigInt("0x" + hex) + return Number(now / BigInt(0x1000)) + }) + if (timestamp >= cutoff) continue + await fs.rm(path.join(DIR, entry), { force: true }).catch(() => {}) + } + }) + + export async function output(text: string, options: Options = {}): Promise { const maxLines = options.maxLines ?? MAX_LINES const maxBytes = options.maxBytes ?? MAX_BYTES const direction = options.direction ?? "head" @@ -39,22 +64,32 @@ export namespace Truncate { out.push(lines[i]) bytes += size } - const removed = hitBytes ? totalBytes - bytes : lines.length - out.length - const unit = hitBytes ? "chars" : "lines" - return { content: `${out.join("\n")}\n\n...${removed} ${unit} truncated...`, truncated: true } + } else { + for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { + const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.unshift(lines[i]) + bytes += size + } } - for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { - const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.unshift(lines[i]) - bytes += size - } const removed = hitBytes ? totalBytes - bytes : lines.length - out.length const unit = hitBytes ? "chars" : "lines" - return { content: `...${removed} ${unit} truncated...\n\n${out.join("\n")}`, truncated: true } + const preview = out.join("\n") + + await init() + const id = Identifier.ascending("tool") + const filepath = path.join(DIR, id) + await Bun.write(Bun.file(filepath), text) + + const message = + direction === "head" + ? `${preview}\n\n...${removed} ${unit} truncated...\n\nFull output written to: ${filepath}\nUse Read or Grep to view the full content.` + : `...${removed} ${unit} truncated...\n\nFull output written to: ${filepath}\nUse Read or Grep to view the full content.\n\n${preview}` + + return { content: message, truncated: true, outputPath: filepath } } } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index bca6626db7..657a5b3182 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -65,7 +65,7 @@ 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.output(result) return { title: "", output: out.truncated ? out.content : result, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 060da0ae76..94385b0f31 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -66,7 +66,7 @@ export namespace Tool { ) } const result = await execute(args, ctx) - const truncated = Truncate.output(result.output) + const truncated = await Truncate.output(result.output) return { ...result, output: truncated.content, diff --git a/packages/opencode/test/session/truncation.test.ts b/packages/opencode/test/session/truncation.test.ts index a3891ed450..e4b952f056 100644 --- a/packages/opencode/test/session/truncation.test.ts +++ b/packages/opencode/test/session/truncation.test.ts @@ -8,41 +8,40 @@ describe("Truncate", () => { describe("output", () => { test("truncates large json file by bytes", async () => { const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text() - const result = Truncate.output(content) + const result = await Truncate.output(content) expect(result.truncated).toBe(true) - expect(Buffer.byteLength(result.content, "utf-8")).toBeLessThanOrEqual(Truncate.MAX_BYTES + 100) expect(result.content).toContain("truncated...") + expect(result.outputPath).toBeDefined() }) - test("returns content unchanged when under limits", () => { + test("returns content unchanged when under limits", async () => { const content = "line1\nline2\nline3" - const result = Truncate.output(content) + const result = await Truncate.output(content) expect(result.truncated).toBe(false) expect(result.content).toBe(content) }) - test("truncates by line count", () => { + test("truncates by line count", async () => { const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") - const result = Truncate.output(lines, { maxLines: 10 }) + const result = await Truncate.output(lines, { maxLines: 10 }) expect(result.truncated).toBe(true) - expect(result.content.split("\n").length).toBeLessThanOrEqual(12) expect(result.content).toContain("...90 lines truncated...") }) - test("truncates by byte count", () => { + test("truncates by byte count", async () => { const content = "a".repeat(1000) - const result = Truncate.output(content, { maxBytes: 100 }) + const result = await Truncate.output(content, { maxBytes: 100 }) expect(result.truncated).toBe(true) expect(result.content).toContain("truncated...") }) - test("truncates from head by default", () => { + test("truncates from head by default", async () => { const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") - const result = Truncate.output(lines, { maxLines: 3 }) + const result = await Truncate.output(lines, { maxLines: 3 }) expect(result.truncated).toBe(true) expect(result.content).toContain("line0") @@ -51,9 +50,9 @@ describe("Truncate", () => { expect(result.content).not.toContain("line9") }) - test("truncates from tail when direction is tail", () => { + test("truncates from tail when direction is tail", async () => { const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") - const result = Truncate.output(lines, { maxLines: 3, direction: "tail" }) + const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" }) expect(result.truncated).toBe(true) expect(result.content).toContain("line7") @@ -69,11 +68,33 @@ describe("Truncate", () => { test("large single-line file truncates with byte message", async () => { const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text() - const result = Truncate.output(content) + const result = await Truncate.output(content) expect(result.truncated).toBe(true) expect(result.content).toContain("chars truncated...") expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES) }) + + test("writes full output to file when truncated", async () => { + const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") + const result = await Truncate.output(lines, { maxLines: 10 }) + + expect(result.truncated).toBe(true) + expect(result.outputPath).toBeDefined() + expect(result.outputPath).toContain("tool_") + expect(result.content).toContain("Full output written to:") + expect(result.content).toContain("Use Read or Grep to view the full content") + + const written = await Bun.file(result.outputPath!).text() + expect(written).toBe(lines) + }) + + test("does not write file when not truncated", async () => { + const content = "short content" + const result = await Truncate.output(content) + + expect(result.truncated).toBe(false) + expect(result.outputPath).toBeUndefined() + }) }) })