mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 14:55:19 +00:00
wip
This commit is contained in:
@@ -4,6 +4,7 @@ import { Provider } from "../provider/provider"
|
||||
import { generateObject, type ModelMessage } from "ai"
|
||||
import { SystemPrompt } from "../session/system"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Truncate } from "../session/truncation"
|
||||
|
||||
import PROMPT_GENERATE from "./generate.txt"
|
||||
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||
@@ -46,7 +47,10 @@ export namespace Agent {
|
||||
const defaults = PermissionNext.fromConfig({
|
||||
"*": "allow",
|
||||
doom_loop: "ask",
|
||||
external_directory: "ask",
|
||||
external_directory: {
|
||||
"*": "ask",
|
||||
[Truncate.DIR]: "allow",
|
||||
},
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
|
||||
@@ -9,6 +9,7 @@ export namespace Identifier {
|
||||
user: "usr",
|
||||
part: "prt",
|
||||
pty: "pty",
|
||||
tool: "tool",
|
||||
} as const
|
||||
|
||||
export function schema(prefix: keyof typeof prefixes) {
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import { Identifier } from "../id/id"
|
||||
import { iife } from "../util/iife"
|
||||
import { lazy } from "../util/lazy"
|
||||
|
||||
export namespace Truncate {
|
||||
export const MAX_LINES = 2000
|
||||
export const MAX_BYTES = 50 * 1024
|
||||
export const DIR = path.join(Global.Path.data, "tool-output")
|
||||
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
export interface Result {
|
||||
content: string
|
||||
truncated: boolean
|
||||
outputPath?: string
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
@@ -13,7 +23,22 @@ export namespace Truncate {
|
||||
direction?: "head" | "tail"
|
||||
}
|
||||
|
||||
export function output(text: string, options: Options = {}): Result {
|
||||
const init = lazy(async () => {
|
||||
const cutoff = Date.now() - RETENTION_MS
|
||||
const entries = await fs.readdir(DIR).catch(() => [] as string[])
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith("tool_")) continue
|
||||
const timestamp = iife(() => {
|
||||
const hex = entry.slice(5, 17)
|
||||
const now = BigInt("0x" + hex)
|
||||
return Number(now / BigInt(0x1000))
|
||||
})
|
||||
if (timestamp >= cutoff) continue
|
||||
await fs.rm(path.join(DIR, entry), { force: true }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
export async function output(text: string, options: Options = {}): Promise<Result> {
|
||||
const maxLines = options.maxLines ?? MAX_LINES
|
||||
const maxBytes = options.maxBytes ?? MAX_BYTES
|
||||
const direction = options.direction ?? "head"
|
||||
@@ -39,22 +64,32 @@ export namespace Truncate {
|
||||
out.push(lines[i])
|
||||
bytes += size
|
||||
}
|
||||
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
||||
const unit = hitBytes ? "chars" : "lines"
|
||||
return { content: `${out.join("\n")}\n\n...${removed} ${unit} truncated...`, truncated: true }
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
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 ? "chars" : "lines"
|
||||
return { content: `...${removed} ${unit} truncated...\n\n${out.join("\n")}`, truncated: true }
|
||||
const preview = out.join("\n")
|
||||
|
||||
await init()
|
||||
const id = Identifier.ascending("tool")
|
||||
const filepath = path.join(DIR, id)
|
||||
await Bun.write(Bun.file(filepath), text)
|
||||
|
||||
const message =
|
||||
direction === "head"
|
||||
? `${preview}\n\n...${removed} ${unit} truncated...\n\nFull output written to: ${filepath}\nUse Read or Grep to view the full content.`
|
||||
: `...${removed} ${unit} truncated...\n\nFull output written to: ${filepath}\nUse Read or Grep to view the full content.\n\n${preview}`
|
||||
|
||||
return { content: message, truncated: true, outputPath: filepath }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export namespace ToolRegistry {
|
||||
description: def.description,
|
||||
execute: async (args, ctx) => {
|
||||
const result = await def.execute(args as any, ctx)
|
||||
const out = Truncate.output(result)
|
||||
const out = await Truncate.output(result)
|
||||
return {
|
||||
title: "",
|
||||
output: out.truncated ? out.content : result,
|
||||
|
||||
@@ -66,7 +66,7 @@ export namespace Tool {
|
||||
)
|
||||
}
|
||||
const result = await execute(args, ctx)
|
||||
const truncated = Truncate.output(result.output)
|
||||
const truncated = await Truncate.output(result.output)
|
||||
return {
|
||||
...result,
|
||||
output: truncated.content,
|
||||
|
||||
@@ -8,41 +8,40 @@ describe("Truncate", () => {
|
||||
describe("output", () => {
|
||||
test("truncates large json file by bytes", async () => {
|
||||
const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
|
||||
const result = Truncate.output(content)
|
||||
const result = await Truncate.output(content)
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(Buffer.byteLength(result.content, "utf-8")).toBeLessThanOrEqual(Truncate.MAX_BYTES + 100)
|
||||
expect(result.content).toContain("truncated...")
|
||||
expect(result.outputPath).toBeDefined()
|
||||
})
|
||||
|
||||
test("returns content unchanged when under limits", () => {
|
||||
test("returns content unchanged when under limits", async () => {
|
||||
const content = "line1\nline2\nline3"
|
||||
const result = Truncate.output(content)
|
||||
const result = await Truncate.output(content)
|
||||
|
||||
expect(result.truncated).toBe(false)
|
||||
expect(result.content).toBe(content)
|
||||
})
|
||||
|
||||
test("truncates by line count", () => {
|
||||
test("truncates by line count", async () => {
|
||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||
const result = Truncate.output(lines, { maxLines: 10 })
|
||||
const result = await Truncate.output(lines, { maxLines: 10 })
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.content.split("\n").length).toBeLessThanOrEqual(12)
|
||||
expect(result.content).toContain("...90 lines truncated...")
|
||||
})
|
||||
|
||||
test("truncates by byte count", () => {
|
||||
test("truncates by byte count", async () => {
|
||||
const content = "a".repeat(1000)
|
||||
const result = Truncate.output(content, { maxBytes: 100 })
|
||||
const result = await Truncate.output(content, { maxBytes: 100 })
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.content).toContain("truncated...")
|
||||
})
|
||||
|
||||
test("truncates from head by default", () => {
|
||||
test("truncates from head by default", async () => {
|
||||
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
||||
const result = Truncate.output(lines, { maxLines: 3 })
|
||||
const result = await Truncate.output(lines, { maxLines: 3 })
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.content).toContain("line0")
|
||||
@@ -51,9 +50,9 @@ describe("Truncate", () => {
|
||||
expect(result.content).not.toContain("line9")
|
||||
})
|
||||
|
||||
test("truncates from tail when direction is tail", () => {
|
||||
test("truncates from tail when direction is tail", async () => {
|
||||
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
||||
const result = Truncate.output(lines, { maxLines: 3, direction: "tail" })
|
||||
const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" })
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.content).toContain("line7")
|
||||
@@ -69,11 +68,33 @@ describe("Truncate", () => {
|
||||
|
||||
test("large single-line file truncates with byte message", async () => {
|
||||
const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
|
||||
const result = Truncate.output(content)
|
||||
const result = await Truncate.output(content)
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.content).toContain("chars truncated...")
|
||||
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
|
||||
})
|
||||
|
||||
test("writes full output to file when truncated", async () => {
|
||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||
const result = await Truncate.output(lines, { maxLines: 10 })
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.outputPath).toBeDefined()
|
||||
expect(result.outputPath).toContain("tool_")
|
||||
expect(result.content).toContain("Full output written to:")
|
||||
expect(result.content).toContain("Use Read or Grep to view the full content")
|
||||
|
||||
const written = await Bun.file(result.outputPath!).text()
|
||||
expect(written).toBe(lines)
|
||||
})
|
||||
|
||||
test("does not write file when not truncated", async () => {
|
||||
const content = "short content"
|
||||
const result = await Truncate.output(content)
|
||||
|
||||
expect(result.truncated).toBe(false)
|
||||
expect(result.outputPath).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user