2026-03-21 04:51:35 +00:00
import { NodePath } from "@effect/platform-node"
2026-04-11 03:06:28 +00:00
import { Cause , Duration , Effect , Layer , Schedule , Context } from "effect"
2026-03-21 04:51:35 +00:00
import path from "path"
2026-03-18 01:59:54 +00:00
import type { Agent } from "../agent/agent"
2026-04-15 14:26:20 +00:00
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
2026-03-21 04:51:35 +00:00
import { evaluate } from "@/permission/evaluate"
import { Identifier } from "../id/id"
2026-04-16 03:15:58 +00:00
import { Log } from "../util"
2026-03-21 04:51:35 +00:00
import { ToolID } from "./schema"
import { TRUNCATION_DIR } from "./truncation-dir"
2026-03-18 01:59:54 +00:00
2026-04-16 03:56:54 +00:00
const log = Log . create ( { service : "truncation" } )
const RETENTION = Duration . days ( 7 )
2026-03-21 04:51:35 +00:00
2026-04-16 03:56:54 +00:00
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const DIR = TRUNCATION_DIR
export const GLOB = path . join ( TRUNCATION_DIR , "*" )
2026-03-21 04:51:35 +00:00
2026-04-16 03:56:54 +00:00
export type Result = { content : string ; truncated : false } | { content : string ; truncated : true ; outputPath : string }
export interface Options {
maxLines? : number
maxBytes? : number
direction ? : "head" | "tail"
}
2026-03-21 04:51:35 +00:00
2026-04-16 03:56:54 +00:00
function hasTaskTool ( agent? : Agent.Info ) {
if ( ! agent ? . permission ) return false
return evaluate ( "task" , "*" , agent . permission ) . action !== "deny"
}
2026-03-21 04:51:35 +00:00
2026-04-16 03:56:54 +00:00
export interface Interface {
readonly cleanup : ( ) = > Effect . Effect < void >
readonly write : ( text : string ) = > Effect . Effect < string >
/ * *
* 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 >
2026-03-18 01:59:54 +00:00
}
2026-04-16 03:56:54 +00:00
export class Service extends Context . 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" , "ascending" , 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 write = Effect . fn ( "Truncate.write" ) ( function * ( text : string ) {
const file = path . join ( TRUNCATION_DIR , ToolID . ascending ( ) )
yield * fs . ensureDir ( TRUNCATION_DIR ) . pipe ( Effect . orDie )
yield * fs . writeFileString ( file , text ) . pipe ( Effect . orDie )
return file
} )
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 = yield * write ( text )
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 , write , output } )
} ) ,
)
export const defaultLayer = layer . pipe ( Layer . provide ( AppFileSystem . defaultLayer ) , Layer . provide ( NodePath . layer ) )