Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155export * as ToolOutput from "./tool-output.js"
import path from "path"import type { Tool } from "./tool.js"import { Context, Duration, Effect, Layer, Schedule } from "effect"import { makeGlobalNode, makeLocationNode } from "@opencode/util/effect/app-node"import { FSUtil } from "@opencode/util/fs-util"import { Global } from "@opencode/util/global"import { FileRetention } from "./file-retention.js"import { Identifier } from "./id/id.js"import { State } from "./state.js"
export const MAX_LINES = 2_000export const MAX_BYTES = 50 * 1024 // 50 KiBexport const RETENTION = Duration.days(7)export const DIRECTORY = "tool-output"
type Result = Tool.NormalizedResult
type Limits = { maxLines: number maxBytes: number}
export type Editor = { configure: (limits: Partial<Limits>) => void}
export interface Interface extends State.Transformable<Editor> { readonly truncate: (result: Result) => Effect.Effect<Result> readonly cleanup: () => Effect.Effect<void>}
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolOutput") {}
const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, directory: string) { const entries = yield* fs.readDirectory(directory).pipe( Effect.map((entries) => entries.filter((entry) => /^tool_[0-9a-f]{12}/.test(entry))), Effect.orElseSucceed(() => []), ) yield* FileRetention.cleanup( fs, entries.map((entry) => path.join(directory, entry)), RETENTION, )})
const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service const global = yield* Global.Service const directory = path.join(global.data, DIRECTORY) const state = State.create<Limits, Editor>({ name: "tool-output", initial: () => ({ maxLines: MAX_LINES, maxBytes: MAX_BYTES }), editor: (editor) => ({ configure: (limits) => { if (limits.maxLines !== undefined) editor.maxLines = limits.maxLines if (limits.maxBytes !== undefined) editor.maxBytes = limits.maxBytes }, }), })
const truncate = Effect.fnUntraced(function* (result: Result) { if (result.metadata?.truncated !== undefined) return result const content = result.content const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n") const limits = state.get() const lines = text.split("\n") if (text.endsWith("\n")) lines.pop() const totalBytes = Buffer.byteLength(text, "utf-8") if (lines.length <= limits.maxLines && totalBytes <= limits.maxBytes) return { ...result, metadata: { ...result.metadata, truncated: false } }
const kept: string[] = [] let bytes = 0 let hitBytes = false for (const line of lines.slice(0, limits.maxLines)) { const size = Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0) if (bytes + size > limits.maxBytes) { hitBytes = true break } kept.push(line) bytes += size } if (!hitBytes && kept.length === lines.length && totalBytes > bytes) hitBytes = true const removed = hitBytes ? totalBytes - bytes : lines.length - kept.length const unit = hitBytes ? (removed === 1 ? "byte" : "bytes") : removed === 1 ? "line" : "lines" const file = path.join(directory, Identifier.ascending("tool")) yield* fs.ensureDir(directory).pipe(Effect.orDie) yield* fs.writeFileString(file, text).pipe(Effect.orDie) const marker = `... ${removed} ${unit} truncated; full content saved to ${file} ...` const bounded: Tool.Content[] = [] let remaining = kept.join("\n").length let seenText = false let marked = false for (const item of content) { if (item.type === "file") { bounded.push(item) continue } if (seenText && remaining > 0) remaining-- seenText = true if (remaining >= item.text.length) { bounded.push(item) remaining -= item.text.length continue } if (remaining > 0) bounded.push({ ...item, text: item.text.slice(0, remaining) }) if (!marked) bounded.push({ type: "text", text: marker }) remaining = 0 marked = true } if (!marked) bounded.push({ type: "text", text: marker }) return { ...result, content: bounded, metadata: { ...result.metadata, truncated: true, outputPath: file }, } })
return Service.of({ transform: state.transform, reload: state.reload, truncate, cleanup: () => cleanup(fs, directory), }) }),)
const cleanupLayer = Layer.effectDiscard( Effect.gen(function* () { const fs = yield* FSUtil.Service const global = yield* Global.Service yield* cleanup(fs, path.join(global.data, DIRECTORY)).pipe( Effect.repeat(Schedule.spaced(Duration.hours(1))), Effect.forkScoped, ) }),)
const cleanupNode = makeGlobalNode({ name: "tool-output-cleanup", layer: cleanupLayer, deps: [FSUtil.node, Global.node],})
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, cleanupNode],})