refactor(core): simplify location filesystem (#31545)

This commit is contained in:
Dax 2026-06-09 14:28:45 -04:00 committed by GitHub
commit 132ef57272
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 1048 additions and 1416 deletions

View file

@ -2,162 +2,32 @@ export * as FileSystem from "./filesystem"
import path from "path"
import { pathToFileURL } from "url"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import { Context, Effect, Layer, Option, Schema, Stream } from "effect"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { EventV2 } from "./event"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { Protected } from "./filesystem/protected"
import { Ripgrep } from "./filesystem/ripgrep"
import { ToolOutputStore } from "./tool-output-store"
import { Search } from "./filesystem/search"
export const ReadInput = Schema.Struct({
path: Schema.String,
path: RelativePath,
})
export type ReadInput = typeof ReadInput.Type
export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024
export const READ_SAMPLE_BYTES = 4 * 1024
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
const MAX_LINE_LENGTH = 2_000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
export class BinaryFileError extends Error {
constructor(readonly resource: string) {
super(`Cannot read binary file: ${resource}`)
this.name = "BinaryFileError"
}
}
const BINARY_EXTENSIONS = new Set([
".zip",
".tar",
".gz",
".exe",
".dll",
".so",
".class",
".jar",
".war",
".7z",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".odt",
".ods",
".odp",
".bin",
".dat",
".obj",
".o",
".a",
".lib",
".wasm",
".pyc",
".pyo",
])
export const isBinary = (resource: string, bytes: Uint8Array) => {
if (BINARY_EXTENSIONS.has(path.extname(resource).toLowerCase())) return true
if (bytes.length === 0) return false
let nonPrintable = 0
for (const byte of bytes) {
if (byte === 0) return true
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
}
return nonPrintable / bytes.length > 0.3
}
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
const supportedImageMime = (bytes: Uint8Array) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
return "image/webp"
}
export class MediaIngestLimitError extends Error {
constructor(
readonly resource: string,
readonly maximumBytes: number,
) {
super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`)
this.name = "MediaIngestLimitError"
}
}
export class TextContent extends Schema.Class<TextContent>("FileSystem.TextContent")({
type: Schema.Literal("text"),
export const Content = Schema.Struct({
uri: Schema.String,
name: Schema.String.pipe(Schema.optional),
content: Schema.String,
encoding: Schema.Literals(["utf8", "base64"]),
mime: Schema.String,
}) {}
export class BinaryContent extends Schema.Class<BinaryContent>("FileSystem.BinaryContent")({
type: Schema.Literal("binary"),
content: Schema.String,
encoding: Schema.Literal("base64"),
mime: Schema.String,
}) {}
export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type"))
}).annotate({ identifier: "FileSystem.Content" })
export type Content = typeof Content.Type
export const TextPageInput = Schema.Struct({
offset: PositiveInt.pipe(Schema.optional),
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional),
})
export type TextPageInput = typeof TextPageInput.Type
export class TextPage extends Schema.Class<TextPage>("FileSystem.TextPage")({
type: Schema.Literal("text-page"),
content: Schema.String,
mime: Schema.String,
offset: PositiveInt,
truncated: Schema.Boolean,
next: PositiveInt.pipe(Schema.optional),
}) {}
export class ReadPath extends Schema.Class<ReadPath>("FileSystem.ReadPath")({
type: Schema.Literals(["file", "directory"]),
resource: Schema.String,
}) {}
export const ListInput = Schema.Struct({
path: Schema.String.pipe(Schema.optional),
path: RelativePath.pipe(Schema.optional),
})
export type ListInput = typeof ListInput.Type
export const ListPageInput = Schema.Struct({
...ListInput.fields,
offset: PositiveInt.pipe(Schema.optional),
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(2_000)).pipe(Schema.optional),
})
export type ListPageInput = typeof ListPageInput.Type
export class ListTarget extends Schema.Class<ListTarget>("FileSystem.ListTarget")({
absolute: Schema.String,
real: Schema.String,
directory: Schema.String,
root: Schema.String,
resource: Schema.String,
}) {}
/** Canonical root and permission resource for Location-scoped search. */
export class RootTarget extends Schema.Class<RootTarget>("FileSystem.RootTarget")({
real: Schema.String,
root: Schema.String,
resource: Schema.String,
type: Schema.Literals(["file", "directory"]),
}) {}
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
path: RelativePath,
uri: Schema.String,
@ -165,12 +35,6 @@ export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
mime: Schema.String,
}) {}
export class ListPage extends Schema.Class<ListPage>("FileSystem.ListPage")({
entries: Schema.Array(Entry),
truncated: Schema.Boolean,
next: PositiveInt.pipe(Schema.optional),
}) {}
export const FindInput = Schema.Struct({
query: Schema.String,
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
@ -185,7 +49,7 @@ export const GrepInput = Schema.Struct({
})
export type GrepInput = typeof GrepInput.Type
export class GrepMatch extends Schema.Class<GrepMatch>("FileSystem.GrepMatch")({
export class GrepMatch extends Schema.Class<GrepMatch>("LocationFileSystem.GrepMatch")({
path: RelativePath,
lines: Schema.String,
line: PositiveInt,
@ -210,21 +74,9 @@ export const Event = {
export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<Content>
readonly resolveReadPath: (input: ReadInput) => Effect.Effect<ReadPath>
readonly readTool: (input: ReadInput, page?: TextPageInput) => Effect.Effect<Content | TextPage>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
/** Resolve a contained canonical search root and its permission resource. */
readonly resolveRoot: (input?: ListInput) => Effect.Effect<RootTarget>
readonly resolveList: (input?: ListInput) => Effect.Effect<ListTarget>
readonly listResolved: (target: ListTarget) => Effect.Effect<Entry[]>
readonly listPage: (input?: ListPageInput) => Effect.Effect<ListPage>
readonly listPageResolved: (
target: ListTarget,
page?: Pick<ListPageInput, "offset" | "limit">,
) => Effect.Effect<ListPage>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
readonly isIgnored: (path: RelativePath, type: "file" | "directory") => boolean
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem") {}
@ -234,47 +86,22 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const global = yield* Effect.serviceOption(Global.Service)
const ripgrep = yield* Ripgrep.Service
const search = yield* Search.Service
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const ignored = ignore()
const gitignore = yield* fs
.readFileString(path.join(location.project.directory, ".gitignore"))
.pipe(Effect.catch(() => Effect.succeed("")))
if (gitignore) ignored.add(gitignore)
const ignorefile = yield* fs
.readFileString(path.join(location.project.directory, ".ignore"))
.pipe(Effect.catch(() => Effect.succeed("")))
if (ignorefile) ignored.add(ignorefile)
const resolve = Effect.fnUntraced(function* (input?: string) {
const managed = path.join(
Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
ToolOutputStore.MANAGED_DIRECTORY,
)
if (input && path.isAbsolute(input)) {
if (path.dirname(input) !== managed || !path.basename(input).startsWith("tool_"))
return yield* Effect.die(new Error("Absolute path is not managed tool output"))
const real = yield* fs.realPath(input).pipe(Effect.orDie)
const managedRoot = yield* fs.realPath(managed).pipe(Effect.orDie)
if (path.dirname(real) !== managedRoot || !path.basename(real).startsWith("tool_"))
return yield* Effect.die(new Error("Path escapes managed tool output"))
return { absolute: input, real, directory: managed, root: managedRoot }
}
const selected = { directory: location.directory, root }
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = path.resolve(location.directory, input ?? ".")
if (!FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, ...selected }
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, directory: location.directory, root }
})
const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) {
const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!real) return
if (!FSUtil.contains(selected.root, real)) return
const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
if (!info) return
const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
if (!type) return
return new Entry({
path: RelativePath.make(path.relative(selected.directory, absolute)),
@ -284,319 +111,73 @@ export const layer = Layer.effect(
})
})
const scan = Effect.fnUntraced(function* () {
if (location.directory === Global.Path.home && location.project.id === "global") {
const protectedNames = Protected.names()
const nested = new Set(["node_modules", "dist", "build", "target", "vendor"])
return (yield* Effect.forEach(
yield* fs.readDirectoryEntries(location.directory).pipe(Effect.orElseSucceed(() => [])),
(item) =>
Effect.gen(function* () {
if (item.type !== "directory" || item.name.startsWith(".") || protectedNames.has(item.name)) return []
const directory = path.join(location.directory, item.name)
return [
item.name + "/",
...(yield* fs.readDirectoryEntries(directory).pipe(Effect.orElseSucceed(() => []))).flatMap((child) =>
child.type === "directory" && !child.name.startsWith(".") && !nested.has(child.name)
? [`${item.name}/${child.name}/`]
: [],
),
]
}),
)).flat()
}
const files = Array.from(yield* ripgrep.files({ cwd: location.directory }).pipe(Stream.runCollect, Effect.orDie))
const dirs = new Set<string>()
for (const file of files) {
let current = file
while (true) {
const directory = path.dirname(current)
if (directory === "." || directory === current) break
current = directory
dirs.add(directory + "/")
}
}
return [...files, ...dirs]
})
const resolveReadPath = Effect.fn("FileSystem.resolveReadPath")(function* (input: ReadInput) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return new ReadPath({
type,
resource: relative,
})
})
const resolveFile = Effect.fnUntraced(function* (input: ReadInput) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return {
real: target.real,
resource: relative,
}
})
const content = (target: { readonly real: string }, bytes: Uint8Array) =>
Effect.gen(function* () {
return Service.of({
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const bytes = yield* fs.readFile(target.real).pipe(Effect.orDie)
const mime = FSUtil.mimeType(target.real)
if (!bytes.includes(0)) {
const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
Effect.option,
)
if (content._tag === "Some") return new TextContent({ type: "text", content: content.value, mime })
}
return new BinaryContent({
type: "binary",
content: Buffer.from(bytes).toString("base64"),
encoding: "base64",
mime,
})
})
const readTool = Effect.fn("FileSystem.readTool")(function* (input: ReadInput, page: TextPageInput = {}) {
const target = yield* resolveFile(input)
return yield* Effect.scoped(
Effect.gen(function* () {
const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie)
const info = yield* file.stat.pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const first = Option.getOrElse(
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || READ_SAMPLE_BYTES)).pipe(Effect.orDie),
() => new Uint8Array(),
)
const mime = supportedImageMime(first)
if (mime) {
if (info.size > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.die(new MediaIngestLimitError(target.resource, MAX_MEDIA_INGEST_BYTES))
const chunks = [first]
let total = first.length
while (total <= MAX_MEDIA_INGEST_BYTES) {
const chunk = yield* file
.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
.pipe(Effect.orDie)
if (Option.isNone(chunk)) break
chunks.push(chunk.value)
total += chunk.value.length
}
if (total > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.die(new MediaIngestLimitError(target.resource, MAX_MEDIA_INGEST_BYTES))
return new BinaryContent({
type: "binary",
content: Buffer.concat(
chunks.map((chunk) => Buffer.from(chunk)),
total,
).toString("base64"),
encoding: "base64",
if (Option.isSome(content))
return {
uri: pathToFileURL(target.real).href,
name: path.basename(target.real),
content: content.value,
encoding: "utf8" as const,
mime,
})
}
if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || isBinary(target.resource, first))
return yield* Effect.die(new BinaryFileError(target.resource))
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
const decoder = new TextDecoder("utf-8", { fatal: true })
const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))]
while (true) {
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(target.resource))
text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true })))
}
text.push(yield* Effect.sync(() => decoder.decode()))
return new TextContent({ type: "text", content: text.join(""), mime: FSUtil.mimeType(target.real) })
}
const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
const lines: string[] = []
const decoder = new TextDecoder("utf-8", { fatal: true })
let pending = ""
let discard = false
let line = 1
let bytes = 0
let found = false
let truncated = false
let next: number | undefined
const append = (input: string) => {
if (line < offset) {
line++
return
}
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
truncated = true
next ??= line
line++
return
}
found = true
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
if (bytes + size > MAX_READ_BYTES) {
truncated = true
next ??= line
line++
return
}
lines.push(text)
bytes += size
line++
}
const consume = (chunk: Uint8Array) => {
if (chunk.includes(0)) throw new BinaryFileError(target.resource)
let text = decoder.decode(chunk, { stream: true })
while (true) {
const index = text.indexOf("\n")
if (index === -1) {
if (!discard) {
pending += text
if (pending.length > MAX_LINE_LENGTH) {
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
discard = true
}
}
break
}
const current = pending + (discard ? "" : text.slice(0, index))
pending = ""
discard = false
text = text.slice(index + 1)
append(current.endsWith("\r") ? current.slice(0, -1) : current)
}
}
yield* Effect.sync(() => consume(first))
while (true) {
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
yield* Effect.sync(() => consume(chunk.value))
}
const tail = yield* Effect.sync(() => decoder.decode())
if (!discard) pending += tail
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`))
const text = lines.join("\n")
return new TextPage({
type: "text-page",
content: text,
mime: FSUtil.mimeType(target.real),
offset,
truncated,
...(next === undefined ? {} : { next }),
})
}),
)
})
const resolveList = Effect.fn("FileSystem.resolveList")(function* (input: ListInput = {}) {
const directory = yield* resolve(input.path)
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
const relative = path.relative(directory.root, directory.real).replaceAll("\\", "/") || "."
return new ListTarget({
...directory,
resource: relative,
})
})
const resolveRoot = Effect.fn("FileSystem.resolveRoot")(function* (input: ListInput = {}) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return new RootTarget({
...target,
resource: relative,
type,
})
})
const listResolved = Effect.fn("FileSystem.listResolved")(function* (directory: ListTarget) {
return yield* fs.readDirectoryEntries(directory.real).pipe(
Effect.orDie,
Effect.flatMap((items) =>
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
concurrency: "unbounded",
}),
),
Effect.map((items) =>
items
.filter((item): item is Entry => item !== undefined)
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
})
const listPageResolved = Effect.fn("FileSystem.listPageResolved")(function* (
target: ListTarget,
page: Pick<ListPageInput, "offset" | "limit"> = {},
) {
type Candidate = Entry | { readonly name: string; readonly type: "file" | "directory" }
const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? 2_000, 2_000)
const items = yield* fs.readDirectoryEntries(target.real).pipe(Effect.orDie)
const candidates = yield* Effect.forEach(
items,
(item): Effect.Effect<Candidate | undefined> => {
if (item.type === "other") return Effect.succeed(undefined)
if (item.type === "symlink") return entry(path.join(target.absolute, item.name), target)
return Effect.succeed({ name: item.name, type: item.type } as const)
},
{ concurrency: 16 },
).pipe(Effect.map((items) => items.filter((item): item is Candidate => item !== undefined)))
candidates.sort((a, b) => {
return a.type === b.type
? (a instanceof Entry ? a.path : a.name).localeCompare(b instanceof Entry ? b.path : b.name)
: a.type === "directory"
? -1
: 1
})
const selected = candidates.slice(offset - 1, offset - 1 + limit)
const entries = yield* Effect.forEach(
selected,
(item) => (item instanceof Entry ? Effect.succeed(item) : entry(path.join(target.absolute, item.name), target)),
{
concurrency: 16,
},
).pipe(Effect.map((items) => items.filter((item): item is Entry => item !== undefined)))
const truncated = offset - 1 + selected.length < candidates.length
return new ListPage({ entries, truncated, ...(truncated ? { next: offset + selected.length } : {}) })
})
return Service.of({
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolveFile(input)
return yield* content(target, yield* fs.readFile(target.real).pipe(Effect.orDie))
}
return {
uri: pathToFileURL(target.real).href,
name: path.basename(target.real),
content: Buffer.from(bytes).toString("base64"),
encoding: "base64" as const,
mime,
}
}),
resolveReadPath,
readTool,
list: Effect.fn("FileSystem.list")(function* (input) {
return yield* listResolved(yield* resolveList(input))
list: Effect.fn("FileSystem.list")(function* (input = {}) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(target.real).pipe(
Effect.orDie,
Effect.flatMap((items) =>
Effect.forEach(items, (item) => entry(path.join(target.absolute, item.name), target), {
concurrency: "unbounded",
}),
),
Effect.map((items) =>
items
.filter((item): item is Entry => item !== undefined)
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
}),
resolveRoot,
resolveList,
listResolved,
listPage: Effect.fn("FileSystem.listPage")(function* (input) {
return yield* listPageResolved(yield* resolveList(input), input)
}),
listPageResolved,
find: Effect.fn("FileSystem.find")(function* (input) {
const items = (yield* scan()).filter((item) => input.type !== "file" || !item.endsWith("/"))
const filtered = items.filter((item) => input.type !== "directory" || item.endsWith("/"))
const sorted = input.query.trim()
? fuzzysort.go(input.query.trim(), filtered, { limit: input.limit ?? 100 }).map((item) => item.target)
: filtered.slice(0, input.limit)
return yield* Effect.forEach(sorted, (item) => entry(path.join(location.directory, item))).pipe(
Effect.map((items) => items.filter((item): item is Entry => item !== undefined)),
const found = yield* search
.file({
cwd: location.directory,
query: input.query,
limit: input.limit,
kind: input.type ?? "all",
})
.pipe(Effect.orDie)
return found.map(
(item) =>
new Entry({
path: RelativePath.make(item.path),
uri: pathToFileURL(path.join(location.directory, item.path)).href,
type: item.type,
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(item.path),
}),
)
}),
grep: Effect.fn("FileSystem.grep")(function* (input) {
return (yield* ripgrep
return (yield* search
.search({
cwd: location.directory,
pattern: input.pattern,
@ -618,13 +199,8 @@ export const layer = Layer.effect(
}),
)
}),
isIgnored: (input, type) =>
ignored.ignores(
path.relative(location.project.directory, path.join(location.directory, input)) +
(type === "directory" ? "/" : ""),
),
})
}),
)
export const locationLayer = layer.pipe(Layer.provide(Ripgrep.defaultLayer))
export const locationLayer = layer

View file

@ -30,6 +30,11 @@ export interface FileInput {
readonly kind?: "file" | "directory" | "all"
}
export interface FileResult {
readonly path: string
readonly type: "file" | "directory"
}
export interface GlobInput {
readonly cwd: string
readonly pattern: string
@ -61,7 +66,7 @@ export interface Interface {
readonly files: Ripgrep.Interface["files"]
readonly tree: Ripgrep.Interface["tree"]
readonly search: (input: Ripgrep.SearchInput) => Effect.Effect<Result, SearchError>
readonly file: (input: FileInput) => Effect.Effect<string[] | undefined, SearchError>
readonly file: (input: FileInput) => Effect.Effect<readonly FileResult[], SearchError>
readonly glob: (input: GlobInput) => Effect.Effect<{ files: string[]; truncated: boolean }, SearchError>
readonly open: (input: { cwd?: string; file: string }) => Effect.Effect<void, SearchError>
readonly warm: (cwd: string) => Effect.Effect<void>
@ -131,17 +136,29 @@ function item(hit: Fff.Hit): Item {
}
}
function collectPaths<T>(items: T[], scores: Array<{ total: number }>, toPath: (item: T) => string): string[] {
const rows = items.flatMap((item, index): Array<{ text: string; score: number }> => {
const text = toPath(item)
if (!text) return []
return [{ text, score: scores[index]?.total ?? 0 }]
function collectPaths<T>(
items: T[],
scores: Array<{ total: number }>,
toResult: (item: T) => FileResult,
): FileResult[] {
const rows = items.flatMap((item, index): Array<FileResult & { score: number }> => {
const result = toResult(item)
if (!result.path) return []
return [{ ...result, score: scores[index]?.total ?? 0 }]
})
rows.sort(
(a, b) => b.score - a.score || a.text.length - b.text.length || (a.text < b.text ? -1 : a.text > b.text ? 1 : 0),
(a, b) =>
b.score - a.score ||
a.path.length - b.path.length ||
(a.path < b.path ? -1 : a.path > b.path ? 1 : 0),
)
return Array.from(new Set(rows.map((item) => item.text)))
const seen = new Set<string>()
return rows.flatMap((item) => {
if (seen.has(item.path)) return []
seen.add(item.path)
return [{ path: item.path, type: item.type }]
})
}
function searchFff(
@ -149,13 +166,16 @@ function searchFff(
kind: "file" | "directory" | "all",
query: string,
opts: { currentFile?: string; pageIndex?: number; pageSize?: number },
): Fff.Result<string[]> {
): Fff.Result<FileResult[]> {
if (kind === "directory") {
const out = pick.directorySearch(query, opts)
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value.items, out.value.scores, (entry) => normalize(entry.relativePath)),
value: collectPaths(out.value.items, out.value.scores, (entry) => ({
path: normalize(entry.relativePath),
type: "directory",
})),
}
}
if (kind === "all") {
@ -163,14 +183,20 @@ function searchFff(
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value.items, out.value.scores, (entry) => normalize(entry.item.relativePath)),
value: collectPaths(out.value.items, out.value.scores, (entry) => ({
path: normalize(entry.item.relativePath),
type: entry.type,
})),
}
}
const out = pick.fileSearch(query, opts)
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value.items, out.value.scores, (entry) => normalize(entry.relativePath)),
value: collectPaths(out.value.items, out.value.scores, (entry) => ({
path: normalize(entry.relativePath),
type: "file",
})),
}
}
@ -232,10 +258,6 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
// and does not await the scan; the native background scan starts as soon as
// the picker exists. The `wait` gate dedupes concurrent creation.
const acquire = Effect.fn("Search.acquire")(function* (cwd: string) {
// The opencode test runtime owns an isolated XDG tree that Windows must
// remove before process exit, so use ripgrep instead of native FFF there.
if (process.env.OPENCODE_TEST_HOME) return undefined
const dir = FSUtil.resolve(cwd)
const existing = state.pick.get(dir)
if (existing) return existing
@ -341,8 +363,9 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
const query = input.query.trim()
const kind = input.kind ?? "file"
const entry = yield* acquire(input.cwd).pipe(Effect.catch(() => Effect.succeed<Picker | undefined>(undefined)))
if (!entry) return undefined
const entry = yield* acquire(input.cwd)
if (!entry) return yield* Effect.fail(new Error("fff is unavailable"))
yield* entry.ready
const dir = FSUtil.resolve(input.cwd)
const limit = input.limit ?? 100
const fffResult = yield* fffSync(`${kind} search`, () =>
@ -354,14 +377,13 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
).pipe(
Effect.catch((error) =>
Effect.logWarning(`fff ${kind} search failed`, { dir, query, error }).pipe(
Effect.as<Fff.Result<string[]> | undefined>(undefined),
Effect.andThen(Effect.fail(error)),
),
),
)
if (!fffResult) return undefined
if (!fffResult.ok) {
yield* Effect.logWarning(`fff ${kind} search failed`, { dir, query, error: fffResult.error })
return undefined
return yield* Effect.fail(new Error(fffResult.error))
}
const rows = fffResult.value
@ -369,7 +391,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
state,
dir,
query,
rows.map((row) => path.join(dir, row)),
rows.map((row) => path.join(dir, row.path)),
)
return rows.slice(0, limit)
})

View file

@ -34,8 +34,8 @@ export class SizeError extends Schema.TaggedErrorClass<SizeError>()("Image.SizeE
export interface Interface {
readonly normalize: (
resource: string,
content: FileSystem.BinaryContent,
) => Effect.Effect<FileSystem.BinaryContent, ResizerUnavailableError | DecodeError | SizeError>
content: FileSystem.Content & { readonly encoding: "base64" },
) => Effect.Effect<FileSystem.Content & { readonly encoding: "base64" }, ResizerUnavailableError | DecodeError | SizeError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
@ -50,7 +50,10 @@ export const layer = Layer.effect(
catch: () => new ResizerUnavailableError(),
}).pipe(Effect.flatMap((adapter) => adapter.make)),
)
const normalize = Effect.fn("Image.normalize")(function* (resource: string, content: FileSystem.BinaryContent) {
const normalize = Effect.fn("Image.normalize")(function* (
resource: string,
content: FileSystem.Content & { readonly encoding: "base64" },
) {
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>

View file

@ -19,7 +19,7 @@ export const make = Effect.gen(function* () {
)
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
content: FileSystem.BinaryContent,
content: FileSystem.Content & { readonly encoding: "base64" },
limits: {
readonly autoResize: boolean
readonly maxWidth: number
@ -72,7 +72,7 @@ export const make = Effect.gen(function* () {
for (const [mime, encode] of encoders) {
const candidate = Buffer.from(encode()).toString("base64")
if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes)
return new FileSystem.BinaryContent({ type: "binary", content: candidate, encoding: "base64", mime })
return { ...content, content: candidate, encoding: "base64" as const, mime }
}
} finally {
resized.free()

View file

@ -19,6 +19,7 @@ import { PermissionV2 } from "./permission"
import { PermissionSaved } from "./permission/saved"
import { FileSystem } from "./filesystem"
import { Watcher } from "./filesystem/watcher"
import { Search } from "./filesystem/search"
import { LocationMutation } from "./location-mutation"
import { LocationSearch } from "./location-search"
import { FileMutation } from "./file-mutation"
@ -124,5 +125,6 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
FetchHttpClient.layer,
ToolOutputStore.defaultCleanupLayer,
ApplicationTools.layer,
Search.defaultLayer,
],
}) {}

View file

@ -2,10 +2,12 @@ export * as LocationSearch from "./location-search"
import path from "path"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { FileSystem } from "./filesystem"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { Ripgrep } from "./ripgrep"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { ToolOutputStore } from "./tool-output-store"
/**
* Location-scoped raw search substrate. Search authority is selected only by
@ -25,7 +27,7 @@ export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESU
export const FilesInput = Schema.Struct({
pattern: Schema.String,
...FileSystem.ListInput.fields,
path: Schema.String.pipe(Schema.optional),
limit: ResultLimit.pipe(Schema.optional),
})
export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSignal }
@ -33,7 +35,7 @@ export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSigna
export const GrepInput = Schema.Struct({
pattern: Schema.String,
include: Schema.String.pipe(Schema.optional),
...FileSystem.ListInput.fields,
path: Schema.String.pipe(Schema.optional),
limit: ResultLimit.pipe(Schema.optional),
})
export type GrepInput = typeof GrepInput.Type & { readonly signal?: AbortSignal }
@ -89,10 +91,37 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const filesystem = yield* FileSystem.Service
const location = yield* Location.Service
const global = yield* Effect.serviceOption(Global.Service)
const ripgrep = yield* Ripgrep.Service
const candidate = Effect.fnUntraced(function* (root: FileSystem.RootTarget, cwd: string, value: string) {
const resolve = Effect.fnUntraced(function* (input?: string) {
const directory = input && path.isAbsolute(input) ? path.dirname(input) : location.directory
const absolute = path.resolve(location.directory, input ?? ".")
if (!path.isAbsolute(input ?? "") && !FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new globalThis.Error("Path escapes the location"))
if (path.isAbsolute(input ?? "")) {
const managed = path.join(
Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
ToolOutputStore.MANAGED_DIRECTORY,
)
if (directory !== managed || !path.basename(absolute).startsWith("tool_"))
return yield* Effect.die(new globalThis.Error("Absolute path is not managed tool output"))
}
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
const root = yield* fs.realPath(directory).pipe(Effect.orDie)
if (!FSUtil.contains(root, real)) return yield* Effect.die(new globalThis.Error("Path escapes the search root"))
const info = yield* fs.stat(real).pipe(Effect.orDie)
const type = info.type === "File" ? ("file" as const) : info.type === "Directory" ? ("directory" as const) : undefined
if (!type) return yield* Effect.die(new globalThis.Error("Search root is not a file or directory"))
return { real, root, resource: slash(path.relative(root, real)) || ".", type }
})
const candidate = Effect.fnUntraced(function* (
root: { readonly real: string; readonly root: string; readonly type: "file" | "directory" },
cwd: string,
value: string,
) {
const absolute = path.resolve(cwd, value)
const lexicallyContained =
root.type === "directory" ? FSUtil.contains(root.real, absolute) : absolute === root.real
@ -115,7 +144,7 @@ export const layer = Layer.effect(
return Service.of({
files: Effect.fn("LocationSearch.files")(function* (input) {
const root = yield* filesystem.resolveRoot(input)
const root = yield* resolve(input.path)
if (root.type !== "directory")
return yield* Effect.die(new globalThis.Error("Files search path must be a directory"))
const result = yield* ripgrep.files({
@ -137,7 +166,7 @@ export const layer = Layer.effect(
})
}),
grep: Effect.fn("LocationSearch.grep")(function* (input) {
const root = yield* filesystem.resolveRoot(input)
const root = yield* resolve(input.path)
const cwd = root.type === "directory" ? root.real : path.dirname(root.real)
const result = yield* ripgrep.grep({
cwd,

View file

@ -1,9 +1,8 @@
import { Schema } from "effect"
import { ProviderMetadata } from "@opencode-ai/llm"
import { ProviderMetadata, ToolContent } from "@opencode-ai/llm"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { NonNegativeInt } from "../schema"
import { ToolOutput } from "../tool-output"
import { V2Schema } from "../v2-schema"
import { FileAttachment, Prompt } from "./prompt"
import { SessionSchema } from "./schema"
@ -360,8 +359,8 @@ export namespace Tool {
...options,
schema: {
...ToolBase,
structured: ToolOutput.Structured,
content: Schema.Array(ToolOutput.Content),
structured: Schema.Record(Schema.String, Schema.Any),
content: Schema.Array(ToolContent),
},
})
export type Progress = typeof Progress.Type
@ -371,8 +370,8 @@ export namespace Tool {
...options,
schema: {
...ToolBase,
structured: ToolOutput.Structured,
content: Schema.Array(ToolOutput.Content),
structured: Schema.Record(Schema.String, Schema.Any),
content: Schema.Array(ToolContent),
outputPaths: Schema.Array(Schema.String).pipe(Schema.optional),
result: Schema.Unknown.pipe(Schema.optional),
provider: Schema.Struct({

View file

@ -1,9 +1,8 @@
export * as SessionMessage from "./message"
import { Schema } from "effect"
import { ProviderMetadata } from "@opencode-ai/llm"
import { ProviderMetadata, ToolContent } from "@opencode-ai/llm"
import { ModelV2 } from "../model"
import { ToolOutput } from "../tool-output"
import { V2Schema } from "../v2-schema"
import { SessionEvent } from "./event"
import { Prompt } from "./prompt"
@ -76,25 +75,25 @@ export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Me
export class ToolStateRunning extends Schema.Class<ToolStateRunning>("Session.Message.ToolState.Running")({
status: Schema.Literal("running"),
input: Schema.Record(Schema.String, Schema.Unknown),
structured: ToolOutput.Structured,
content: ToolOutput.Content.pipe(Schema.Array),
structured: Schema.Record(Schema.String, Schema.Any),
content: ToolContent.pipe(Schema.Array),
}) {}
export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Session.Message.ToolState.Completed")({
status: Schema.Literal("completed"),
input: Schema.Record(Schema.String, Schema.Unknown),
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
content: ToolOutput.Content.pipe(Schema.Array),
content: ToolContent.pipe(Schema.Array),
outputPaths: SessionEvent.Tool.Success.data.fields.outputPaths,
structured: ToolOutput.Structured,
structured: Schema.Record(Schema.String, Schema.Any),
result: SessionEvent.Tool.Success.data.fields.result,
}) {}
export class ToolStateError extends Schema.Class<ToolStateError>("Session.Message.ToolState.Error")({
status: Schema.Literal("error"),
input: Schema.Record(Schema.String, Schema.Unknown),
content: ToolOutput.Content.pipe(Schema.Array),
structured: ToolOutput.Structured,
content: ToolContent.pipe(Schema.Array),
structured: Schema.Record(Schema.String, Schema.Any),
error: SessionEvent.UnknownError,
result: SessionEvent.Tool.Failed.data.fields.result,
}) {}

View file

@ -1,8 +1,7 @@
import {
ToolOutput as LLMToolOutput,
ToolOutput,
type LLMEvent,
type ProviderMetadata,
type ToolOutput as LLMToolOutputType,
type ToolResultValue,
type Usage,
} from "@opencode-ai/llm"
@ -45,13 +44,13 @@ const message = (value: unknown) => {
}
}
type ToolOutput =
| { readonly structured: Record<string, unknown>; readonly content: LLMToolOutputType["content"] }
type SettledOutput =
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
| { readonly error: { readonly type: "unknown"; readonly message: string } }
const settledOutput = (value: LLMToolOutputType | undefined, result: ToolResultValue): ToolOutput => {
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
const settled = value ?? LLMToolOutput.fromResultValue(result)
const settled = value ?? ToolOutput.fromResultValue(result)
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
return { structured: record(settled.structured), content: settled.content }
}

View file

@ -38,9 +38,8 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => {
if (tool.state.status === "completed") {
// TODO: Materialize remote URL and managed file sources before provider-history lowering.
// ToolOutput.toResultValue intentionally rejects unmaterialized sources rather than
// guessing whether a provider can fetch them or leaking host-local resource paths.
// TODO: Materialize remote and managed URIs before provider-history lowering.
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
const result =
tool.provider?.executed === true && tool.state.result !== undefined
? tool.state.result

View file

@ -1,11 +0,0 @@
export * as ToolOutput from "./tool-output"
export {
ToolContent as Content,
ToolFileContent as FileContent,
ToolTextContent as TextContent,
toolFile as file,
toolText as text,
} from "@opencode-ai/llm"
import { Schema } from "effect"
export const Structured = Schema.Record(Schema.String, Schema.Any)

View file

@ -1,6 +1,6 @@
export * as ApplyPatchTool from "./apply-patch"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@ -59,7 +59,7 @@ export const layer = Layer.effectDiscard(
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (path: string) => {

View file

@ -1,7 +1,7 @@
export * as BashTool from "./bash"
import path from "path"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "../config"
@ -119,7 +119,7 @@ export const layer = Layer.effectDiscard(
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })],
toModelOutput: ({ output }) => [{ type: "text", text: modelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
const source = {

View file

@ -8,6 +8,7 @@ import { GlobTool } from "./glob"
import { GrepTool } from "./grep"
import { QuestionTool } from "./question"
import { ReadTool } from "./read"
import { ReadToolFileSystem } from "./read-filesystem"
import { SkillTool } from "./skill"
import { TodoWriteTool } from "./todowrite"
import { WebFetchTool } from "./webfetch"
@ -34,7 +35,7 @@ export const locationLayer = Layer.mergeAll(
GlobTool.layer,
GrepTool.layer,
QuestionTool.layer,
ReadTool.layer,
ReadTool.layer.pipe(Layer.provide(ReadToolFileSystem.layer)),
SkillTool.layer,
TodoWriteTool.layer,
WebFetchTool.layer,

View file

@ -6,7 +6,7 @@
*/
export * as EditTool from "./edit"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@ -103,7 +103,7 @@ export const layer = Layer.effectDiscard(
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
toolText({ type: "text", text: toModelOutput(output, input.oldString, input.newString) }),
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
],
execute: (input, context) => {
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>

View file

@ -1,8 +1,7 @@
export * as GlobTool from "./glob"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { LocationSearch } from "../location-search"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
@ -42,7 +41,6 @@ export const toModelOutput = (output: ModelOutput) => {
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const filesystem = yield* FileSystem.Service
const search = yield* LocationSearch.Service
const permission = yield* PermissionV2.Service
@ -53,16 +51,15 @@ export const layer = Layer.effectDiscard(
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Input,
output: LocationSearch.FilesResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
const root = yield* filesystem.resolveRoot({ path: input.path })
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: root.resource,
root: input.path ?? ".",
path: input.path,
limit: input.limit,
},

View file

@ -1,8 +1,7 @@
export * as GrepTool from "./grep"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { LocationSearch } from "../location-search"
import { Ripgrep } from "../ripgrep"
import { PermissionV2 } from "../permission"
@ -57,7 +56,6 @@ export const toModelOutput = (output: Output) => {
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const filesystem = yield* FileSystem.Service
const search = yield* LocationSearch.Service
const permission = yield* PermissionV2.Service
@ -68,16 +66,15 @@ export const layer = Layer.effectDiscard(
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Input,
output: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
const root = yield* filesystem.resolveRoot(input)
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: root.resource,
root: input.path ?? ".",
path: input.path,
include: input.include,
limit: input.limit,

View file

@ -1,6 +1,6 @@
export * as QuestionTool from "./question"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
@ -55,7 +55,7 @@ export const layer = Layer.effectDiscard(
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
toolText({ type: "text", text: toModelOutput(input.questions, output.answers) }),
{ type: "text", text: toModelOutput(input.questions, output.answers) },
],
execute: (input, context) =>
permission

View file

@ -0,0 +1,275 @@
export * as ReadToolFileSystem from "./read-filesystem"
import path from "path"
import { pathToFileURL } from "url"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { AbsolutePath, PositiveInt, RelativePath } from "../schema"
export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
const MAX_LINE_LENGTH = 2_000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
export class BinaryFileError extends Error {
constructor(readonly resource: string) {
super(`Cannot read binary file: ${resource}`)
this.name = "BinaryFileError"
}
}
export class MediaIngestLimitError extends Error {
constructor(
readonly resource: string,
readonly maximumBytes: number,
) {
super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`)
this.name = "MediaIngestLimitError"
}
}
export const PageInput = Schema.Struct({
offset: PositiveInt.pipe(Schema.optional),
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional),
})
export type PageInput = typeof PageInput.Type
export class TextPage extends Schema.Class<TextPage>("ReadTool.TextPage")({
type: Schema.Literal("text-page"),
content: Schema.String,
mime: Schema.String,
offset: PositiveInt,
truncated: Schema.Boolean,
next: PositiveInt.pipe(Schema.optional),
}) {}
export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
entries: Schema.Array(FileSystem.Entry),
truncated: Schema.Boolean,
next: PositiveInt.pipe(Schema.optional),
}) {}
export interface Interface {
readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory">
readonly read: (path: AbsolutePath, resource: string, page?: PageInput) => Effect.Effect<FileSystem.Content | TextPage>
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
const extensions = new Set([
".zip", ".tar", ".gz", ".exe", ".dll", ".so", ".class", ".jar", ".war", ".7z", ".doc", ".docx",
".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp", ".bin", ".dat", ".obj", ".o", ".a",
".lib", ".wasm", ".pyc", ".pyo",
])
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
const imageMime = (bytes: Uint8Array) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
return "image/webp"
}
const binary = (resource: string, bytes: Uint8Array) => {
if (extensions.has(path.extname(resource).toLowerCase())) return true
if (bytes.length === 0) return false
let nonPrintable = 0
for (const byte of bytes) {
if (byte === 0) return true
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
}
return nonPrintable / bytes.length > 0.3
}
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
const info = yield* fs.stat(input).pipe(Effect.orDie)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
return type
})
export const read = Effect.fn("ReadTool.read")(function* (
fs: FSUtil.Interface,
input: string,
resource: string,
page: PageInput = {},
) {
const real = yield* fs.realPath(input).pipe(Effect.orDie)
return yield* Effect.scoped(
Effect.gen(function* () {
const file = yield* fs.open(real, { flag: "r" }).pipe(Effect.orDie)
const info = yield* file.stat.pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const first = Option.getOrElse(
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)).pipe(Effect.orDie),
() => new Uint8Array(),
)
const mime = imageMime(first)
if (mime) {
if (info.size > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES))
const chunks = [first]
let total = first.length
while (total <= MAX_MEDIA_INGEST_BYTES) {
const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total)).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
chunks.push(chunk.value)
total += chunk.value.length
}
if (total > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.die(new MediaIngestLimitError(resource, MAX_MEDIA_INGEST_BYTES))
return {
uri: pathToFileURL(real).href,
name: path.basename(real),
content: Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total).toString("base64"),
encoding: "base64" as const,
mime,
}
}
if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || binary(resource, first))
return yield* Effect.die(new BinaryFileError(resource))
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
const decoder = new TextDecoder("utf-8", { fatal: true })
const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))]
while (true) {
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(resource))
text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true })))
}
text.push(yield* Effect.sync(() => decoder.decode()))
return {
uri: pathToFileURL(real).href,
name: path.basename(real),
content: text.join(""),
encoding: "utf8" as const,
mime: FSUtil.mimeType(real),
}
}
const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
const lines: string[] = []
const decoder = new TextDecoder("utf-8", { fatal: true })
let pending = ""
let discard = false
let line = 1
let bytes = 0
let found = false
let truncated = false
let next: number | undefined
const append = (input: string) => {
if (line < offset) {
line++
return
}
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
truncated = true
next ??= line++
return
}
found = true
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
if (bytes + size > MAX_READ_BYTES) {
truncated = true
next ??= line++
return
}
lines.push(text)
bytes += size
line++
}
const consume = (chunk: Uint8Array) => {
if (chunk.includes(0)) throw new BinaryFileError(resource)
let text = decoder.decode(chunk, { stream: true })
while (true) {
const index = text.indexOf("\n")
if (index === -1) {
if (!discard) {
pending += text
if (pending.length > MAX_LINE_LENGTH) {
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
discard = true
}
}
break
}
const current = pending + (discard ? "" : text.slice(0, index))
pending = ""
discard = false
text = text.slice(index + 1)
append(current.endsWith("\r") ? current.slice(0, -1) : current)
}
}
yield* Effect.sync(() => consume(first))
while (true) {
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
yield* Effect.sync(() => consume(chunk.value))
}
const tail = yield* Effect.sync(() => decoder.decode())
if (!discard) pending += tail
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`))
return new TextPage({
type: "text-page",
content: lines.join("\n"),
mime: FSUtil.mimeType(real),
offset,
truncated,
...(next === undefined ? {} : { next }),
})
}),
)
})
export const list = Effect.fn("ReadTool.list")(function* (
fs: FSUtil.Interface,
input: string,
page: PageInput = {},
) {
const real = yield* fs.realPath(input).pipe(Effect.orDie)
const items = yield* fs.readDirectoryEntries(real).pipe(Effect.orDie)
const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
const entries = yield* Effect.forEach(
items,
(item) =>
Effect.gen(function* () {
const absolute = path.join(real, item.name)
const target = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!target || !FSUtil.contains(real, target)) return
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.void))
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
if (!type) return
return new FileSystem.Entry({
path: RelativePath.make(item.name),
uri: pathToFileURL(target).href,
type,
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(target),
})
}),
{ concurrency: 16 },
)
const visible = entries
.filter((item): item is FileSystem.Entry => item !== undefined)
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1))
const selected = visible.slice(offset - 1, offset - 1 + limit)
const truncated = offset - 1 + selected.length < visible.length
return new ListPage({ entries: selected, truncated, ...(truncated ? { next: offset + selected.length } : {}) })
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
return Service.of({
inspect: (path) => inspect(fs, path),
read: (path, resource, page) => read(fs, path, resource, page),
list: (path, page) => list(fs, path, page),
})
}),
)

View file

@ -1,31 +1,38 @@
export * as ReadTool from "./read"
import { ToolFailure } from "@opencode-ai/llm"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Image } from "../image"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
import { AbsolutePath } from "../schema"
import { ReadToolFileSystem } from "./read-filesystem"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "read"
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
const LocationInput = Schema.Struct({
...FileSystem.ReadInput.fields,
offset: FileSystem.ListPageInput.fields.offset.annotate({
path: Schema.String,
offset: ReadToolFileSystem.PageInput.fields.offset.annotate({
description: "The 1-based directory entry or text line offset to start reading from",
}),
limit: FileSystem.ListPageInput.fields.limit.annotate({
limit: ReadToolFileSystem.PageInput.fields.limit.annotate({
description: "The maximum number of directory entries or text lines to read",
}),
})
const Input = LocationInput
const Output = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const filesystem = yield* FileSystem.Service
const fs = yield* FSUtil.Service
const reader = yield* ReadToolFileSystem.Service
const location = yield* Location.Service
const image = yield* Image.Service
const permission = yield* PermissionV2.Service
@ -33,11 +40,12 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description:
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths are read directly.",
input: Input,
output: Output,
toModelOutput: ({ input, output }) => {
if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
return []
return [
{ type: "text", text: "Image read successfully" },
{ type: "file", data: output.content, mime: output.mime, name: input.path },
@ -45,33 +53,43 @@ export const layer = Layer.effectDiscard(
},
execute: (input, context) => {
return Effect.gen(function* () {
const resolved = yield* filesystem.resolveReadPath(input)
const absolute = path.resolve(location.directory, input.path)
const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory
if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the allowed read root"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
const root = yield* fs.realPath(selected).pipe(Effect.orDie)
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the allowed read root"))
const resource = path.relative(root, real).replaceAll("\\", "/") || "."
const target = AbsolutePath.make(real)
const type = yield* reader.inspect(target)
yield* permission.assert({
action: name,
resources: [resolved.resource],
resources: [resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
if (resolved.type === "directory") return yield* filesystem.listPage(input)
const content = yield* filesystem.readTool(input, {
if (type === "directory")
return yield* reader.list(target, { offset: input.offset, limit: input.limit })
const content = yield* reader.read(target, resource, {
offset: input.offset,
limit: input.limit,
})
if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
return yield* image
.normalize(resolved.resource, content)
.normalize(resource, { ...content, encoding: "base64" })
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
}
if (content.type === "binary")
return yield* Effect.fail(new FileSystem.BinaryFileError(resolved.resource))
if ("encoding" in content && content.encoding === "base64")
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError(resource))
return content
}).pipe(
Effect.mapError((error) => {
const message =
error instanceof FileSystem.BinaryFileError ||
error instanceof FileSystem.MediaIngestLimitError ||
error instanceof ReadToolFileSystem.BinaryFileError ||
error instanceof ReadToolFileSystem.MediaIngestLimitError ||
error instanceof Image.DecodeError ||
error instanceof Image.SizeError
? error.message

View file

@ -1,6 +1,6 @@
export * as ToolRegistry from "./registry"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolSettlement } from "@opencode-ai/llm"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
import { Context, Effect, Layer, Scope } from "effect"
import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission"
@ -30,7 +30,9 @@ export interface Materialization {
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
}
export interface Settlement extends ToolSettlement {
export interface Settlement {
readonly result: ToolResultValue
readonly output?: ToolOutput
readonly outputPaths?: ReadonlyArray<string>
}

View file

@ -2,7 +2,7 @@ export * as SkillTool from "./skill"
import path from "path"
import { pathToFileURL } from "url"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FSUtil } from "../fs-util"
import { PluginBoot } from "../plugin/boot"
@ -68,7 +68,7 @@ export const layer = Layer.effectDiscard(
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
const current = yield* skills.list()

View file

@ -1,6 +1,6 @@
export * as TodoWriteTool from "./todowrite"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { PermissionV2 } from "../permission"
import { SessionTodo } from "../session/todo"
@ -33,7 +33,7 @@ export const layer = Layer.effectDiscard(
"Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({

View file

@ -93,19 +93,20 @@ export function make<Input extends SchemaType<any>, Output extends SchemaType<an
),
),
Effect.map((output) =>
ToolOutput.make(
output,
config.toModelOutput?.({ input, output }).map((part) =>
({
structured: output,
content:
config.toModelOutput?.({ input, output }).map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
source: { type: "data" as const, data: part.data },
uri: `data:${part.mime};base64,${part.data}`,
mime: part.mime,
name: part.name,
},
) ?? (typeof output === "string" ? [{ type: "text", text: output }] : []),
),
) ?? (typeof output === "string" ? [{ type: "text" as const, text: output }] : []),
}),
),
),
),

View file

@ -1,6 +1,6 @@
export * as WebFetchTool from "./webfetch"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2"
@ -136,7 +136,7 @@ export const layer = Layer.effectDiscard(
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
yield* Effect.try({

View file

@ -1,6 +1,6 @@
export * as WebSearchTool from "./websearch"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { truthy } from "../flag/flag"
@ -194,7 +194,7 @@ export const layer = Layer.effectDiscard(
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })],
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)
return Effect.gen(function* () {

View file

@ -6,7 +6,7 @@
*/
export * as WriteTool from "./write"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { LocationMutation } from "../location-mutation"
@ -57,7 +57,7 @@ export const layer = Layer.effectDiscard(
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
const source = {