| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199 |
- export * as ToolOutputStore from "./tool-output-store"
- import path from "path"
- import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
- import { Config } from "./config"
- import { FSUtil } from "./fs-util"
- import { Global } from "./global"
- import { SessionSchema } from "./session/schema"
- import { Identifier } from "./util/identifier"
- import type { ToolOutput } from "@opencode-ai/llm"
- export const MAX_LINES = 2_000
- export const MAX_BYTES = 50 * 1024
- export const RETENTION = Duration.days(7)
- export const MANAGED_DIRECTORY = "tool-output"
- export interface BoundInput {
- readonly sessionID: SessionSchema.ID
- readonly toolCallID: string
- readonly output: ToolOutput
- }
- export interface BoundResult {
- readonly output: ToolOutput
- readonly outputPaths: ReadonlyArray<string>
- }
- export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolOutputStore.StorageError", {
- operation: Schema.Literals(["encode", "write"]),
- cause: Schema.Defect(),
- }) {}
- export type Error = StorageError
- export interface Interface {
- readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
- readonly bound: (input: BoundInput) => Effect.Effect<BoundResult, Error>
- readonly cleanup: () => Effect.Effect<void>
- }
- export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolOutputStore") {}
- const takePrefix = (input: string, maximumBytes: number) => {
- let bytes = 0
- let content = ""
- for (const char of input) {
- const size = Buffer.byteLength(char, "utf-8")
- if (bytes + size > maximumBytes) break
- content += char
- bytes += size
- }
- return content
- }
- const takeSuffix = (input: string, maximumBytes: number) => {
- let bytes = 0
- const content: string[] = []
- for (const char of Array.from(input).toReversed()) {
- const size = Buffer.byteLength(char, "utf-8")
- if (bytes + size > maximumBytes) break
- content.unshift(char)
- bytes += size
- }
- return content.join("")
- }
- const preview = (text: string, maxLines: number, maxBytes: number) => {
- const lines = text.split("\n")
- const headLines = Math.ceil(maxLines / 2)
- const tailLines = Math.floor(maxLines / 2)
- const sampled =
- lines.length <= maxLines
- ? text
- : [
- lines.slice(0, headLines).join("\n"),
- ...(tailLines > 0 ? [lines.slice(lines.length - tailLines).join("\n")] : []),
- ].join("\n")
- if (Buffer.byteLength(sampled, "utf-8") <= maxBytes) {
- return lines.length <= maxLines
- ? { head: sampled, tail: "" }
- : {
- head: lines.slice(0, headLines).join("\n"),
- tail: tailLines > 0 ? lines.slice(lines.length - tailLines).join("\n") : "",
- }
- }
- const headBytes = Math.ceil(maxBytes / 2)
- const tailBytes = Math.floor(maxBytes / 2)
- return { head: takePrefix(sampled, headBytes), tail: takeSuffix(sampled, tailBytes) }
- }
- const boundedPreview = (text: string, marker: string, maxLines: number, maxBytes: number) => {
- const markerOnly = takePrefix(marker, maxBytes).split("\n").slice(0, maxLines).join("\n")
- const markerBytes = Buffer.byteLength(marker, "utf-8")
- if (maxLines <= 4 || maxBytes <= markerBytes + 4) return markerOnly
- const bounded = preview(text, maxLines - 4, maxBytes - markerBytes - 4)
- return bounded.tail ? `${bounded.head}\n\n${marker}\n\n${bounded.tail}` : `${bounded.head}\n\n${marker}`
- }
- const lineCount = (text: string) => {
- let count = 1
- for (const char of text) if (char === "\n") count++
- return count
- }
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const fs = yield* FSUtil.Service
- const global = yield* Global.Service
- const config = yield* Effect.serviceOption(Config.Service)
- const directory = path.join(global.data, MANAGED_DIRECTORY)
- const limits = Effect.fn("ToolOutputStore.limits")(function* () {
- if (Option.isNone(config)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
- const entries = yield* config.value.entries().pipe(Effect.catch(() => Effect.succeed([] as Config.Entry[])))
- const configured = Object.assign(
- {},
- ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info.tool_output ?? {}] : [])),
- )
- return { maxLines: configured.max_lines ?? MAX_LINES, maxBytes: configured.max_bytes ?? MAX_BYTES }
- })
- const write = Effect.fn("ToolOutputStore.write")(function* (content: string) {
- const file = path.join(directory, `tool_${Identifier.ascending()}`)
- yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
- yield* fs
- .writeFileString(file, content, { flag: "wx" })
- .pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
- return file
- })
- const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
- const outputLimits = yield* limits()
- const media = input.output.content.filter((item) => item.type === "file")
- const text = input.output.content.filter((item) => item.type === "text")
- const contextual =
- input.output.content.length === 0
- ? yield* Effect.try({
- try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured),
- catch: (cause) => new StorageError({ operation: "encode", cause }),
- })
- : text.map((item) => item.text).join("")
- if (
- lineCount(contextual) <= outputLimits.maxLines &&
- Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
- )
- return {
- output: input.output,
- outputPaths: [],
- }
- const outputPath = yield* write(contextual)
- const marker = `... output truncated; full content saved to ${outputPath} ...`
- return {
- output: {
- structured: input.output.structured,
- content: [
- {
- type: "text" as const,
- text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
- },
- ...media,
- ],
- },
- outputPaths: [outputPath],
- }
- })
- const cleanup = Effect.fn("ToolOutputStore.cleanup")(function* () {
- const entries = yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed([])))
- const cutoff = Date.now() - Duration.toMillis(RETENTION)
- for (const entry of entries) {
- if (!entry.startsWith("tool_")) continue
- const file = path.join(directory, entry)
- const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.void))
- const modified = info?.mtime.pipe(
- Option.map((date) => date.getTime()),
- Option.getOrElse(() => 0),
- )
- if (modified !== undefined && modified < cutoff) yield* fs.remove(file).pipe(Effect.catch(() => Effect.void))
- }
- })
- return Service.of({ limits, bound, cleanup })
- }),
- )
- export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.defaultLayer))
- /** Runs retention scanning once globally rather than once per active Location. */
- export const cleanupLayer = Layer.effectDiscard(
- Effect.gen(function* () {
- const store = yield* Service
- yield* store.cleanup().pipe(Effect.repeat(Schedule.spaced(Duration.hours(1))), Effect.forkScoped)
- }),
- )
- export const defaultCleanupLayer = Layer.merge(defaultLayer, cleanupLayer.pipe(Layer.provide(defaultLayer)))
|