mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-01 23:02:26 +00:00
138 lines
5.3 KiB
TypeScript
138 lines
5.3 KiB
TypeScript
import { NodePath } from "@effect/platform-node"
|
|
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
|
|
import path from "path"
|
|
import type { Agent } from "../agent/agent"
|
|
import { AppFileSystem } from "@/filesystem"
|
|
import { PermissionNext } from "../permission"
|
|
import { Identifier } from "../id/id"
|
|
import { Log } from "../util/log"
|
|
import { ToolID } from "./schema"
|
|
import { TRUNCATION_DIR } from "./truncation-dir"
|
|
|
|
export namespace TruncateEffect {
|
|
const log = Log.create({ service: "truncation" })
|
|
const RETENTION = Duration.days(7)
|
|
|
|
export const MAX_LINES = 2000
|
|
export const MAX_BYTES = 50 * 1024
|
|
export const DIR = TRUNCATION_DIR
|
|
export const GLOB = path.join(TRUNCATION_DIR, "*")
|
|
|
|
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
|
|
|
|
export interface Options {
|
|
maxLines?: number
|
|
maxBytes?: number
|
|
direction?: "head" | "tail"
|
|
}
|
|
|
|
function hasTaskTool(agent?: Agent.Info) {
|
|
if (!agent?.permission) return false
|
|
return PermissionNext.evaluate("task", "*", agent.permission).action !== "deny"
|
|
}
|
|
|
|
export interface Interface {
|
|
readonly cleanup: () => Effect.Effect<void>
|
|
/**
|
|
* Returns output unchanged when it fits within the limits, otherwise writes the full text
|
|
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
|
|
*/
|
|
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
|
|
}
|
|
|
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
|
|
|
|
export const layer = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const fs = yield* AppFileSystem.Service
|
|
|
|
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
|
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
|
|
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
|
|
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
|
|
Effect.catch(() => Effect.succeed([])),
|
|
)
|
|
for (const entry of entries) {
|
|
if (Identifier.timestamp(entry) >= cutoff) continue
|
|
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
|
|
}
|
|
})
|
|
|
|
const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
|
|
const maxLines = options.maxLines ?? MAX_LINES
|
|
const maxBytes = options.maxBytes ?? MAX_BYTES
|
|
const direction = options.direction ?? "head"
|
|
const lines = text.split("\n")
|
|
const totalBytes = Buffer.byteLength(text, "utf-8")
|
|
|
|
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
|
return { content: text, truncated: false } as const
|
|
}
|
|
|
|
const out: string[] = []
|
|
let i = 0
|
|
let bytes = 0
|
|
let hitBytes = false
|
|
|
|
if (direction === "head") {
|
|
for (i = 0; i < lines.length && i < maxLines; i++) {
|
|
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
|
|
if (bytes + size > maxBytes) {
|
|
hitBytes = true
|
|
break
|
|
}
|
|
out.push(lines[i])
|
|
bytes += size
|
|
}
|
|
} 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
|
|
}
|
|
}
|
|
|
|
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
|
const unit = hitBytes ? "bytes" : "lines"
|
|
const preview = out.join("\n")
|
|
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
|
|
|
|
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
|
|
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
|
|
|
|
const hint = hasTaskTool(agent)
|
|
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
|
|
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
|
|
|
|
return {
|
|
content:
|
|
direction === "head"
|
|
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
|
|
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
|
|
truncated: true,
|
|
outputPath: file,
|
|
} as const
|
|
})
|
|
|
|
yield* cleanup().pipe(
|
|
Effect.catchCause((cause) => {
|
|
log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
|
|
return Effect.void
|
|
}),
|
|
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
|
Effect.delay(Duration.minutes(1)),
|
|
Effect.forkScoped,
|
|
)
|
|
|
|
return Service.of({ cleanup, output })
|
|
}),
|
|
)
|
|
|
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
|
}
|