+21

![opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



James Long
Brendan Allan
Kit Langton
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Affan Ali
affanali2k3
Frank
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
Aiden Cline
Jay V
Dax Raad
Aarav Sareen
OpeOginni
Luke Parker
Ben Guthrie
Dax
Filip
Max Anderson
Brendan Allan
Jack
Shoubhit Dash
Dustin Deus
starptech
Aiden Cline
usrnk1
Jay
runvip
opencode
Julian Coy
Vladimir Glafirov
8c94e9005f
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com> Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Jay V <air@live.ca> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Ben Guthrie <benjee.012@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com> Co-authored-by: Max Anderson <max.a.anderson95@gmail.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
212 lines
7.8 KiB
TypeScript
212 lines
7.8 KiB
TypeScript
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 { makeGlobalNode, makeLocationNode } from "./effect/app-node"
|
|
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(),
|
|
}) {
|
|
override get message() {
|
|
const detail = this.cause instanceof Error ? this.cause.message : String(this.cause)
|
|
return `Failed to ${this.operation} tool output${detail ? `: ${detail}` : ""}`
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Config.node] })
|
|
|
|
export const nodeWithoutConfig = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node] })
|
|
|
|
/** 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 cleanupNode = makeGlobalNode({
|
|
name: "tool-output-cleanup",
|
|
layer: Layer.merge(layer, cleanupLayer.pipe(Layer.provide(layer))),
|
|
deps: [FSUtil.node, Global.node],
|
|
})
|