Files
neuron/packages/opencode/src/tool/read.ts
T
Dax f993541e0b Refactor to support multiple instances inside single opencode process (#2360)
This release has a bunch of minor breaking changes if you are using opencode plugins or sdk

1. storage events have been removed (we might bring this back but had some issues)
2. concept of `app` is gone - there is a new concept called `project` and endpoints to list projects and get the current project
3. plugin receives `directory` which is cwd and `worktree` which is where the root of the project is if it's a git repo
4. the session.chat function has been renamed to session.prompt in sdk. it no longer requires model to be passed in (model is now an object)
5. every endpoint takes an optional `directory` parameter to operate as though opencode is running in that directory
2025-09-01 17:15:49 -04:00

163 lines
4.7 KiB
TypeScript

import { z } from "zod"
import * as fs from "fs"
import * as path from "path"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
export const ReadTool = Tool.define("read", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The path to the file to read"),
offset: z.coerce.number().describe("The line number to start reading from (0-based)").optional(),
limit: z.coerce.number().describe("The number of lines to read (defaults to 2000)").optional(),
}),
async execute(params, ctx) {
let filepath = params.filePath
if (!path.isAbsolute(filepath)) {
filepath = path.join(process.cwd(), filepath)
}
if (!ctx.extra?.["bypassCwdCheck"] && !Filesystem.contains(Instance.directory, filepath)) {
throw new Error(`File ${filepath} is not in the current working directory`)
}
const file = Bun.file(filepath)
if (!(await file.exists())) {
const dir = path.dirname(filepath)
const base = path.basename(filepath)
const dirEntries = fs.readdirSync(dir)
const suggestions = dirEntries
.filter(
(entry) =>
entry.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(entry.toLowerCase()),
)
.map((entry) => path.join(dir, entry))
.slice(0, 3)
if (suggestions.length > 0) {
throw new Error(`File not found: ${filepath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`)
}
throw new Error(`File not found: ${filepath}`)
}
const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset || 0
const isImage = isImageFile(filepath)
if (isImage) throw new Error(`This is an image file of type: ${isImage}\nUse a different tool to process images`)
const isBinary = await isBinaryFile(filepath, file)
if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`)
const lines = await file.text().then((text) => text.split("\n"))
const raw = lines.slice(offset, offset + limit).map((line) => {
return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line
})
const content = raw.map((line, index) => {
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
})
const preview = raw.slice(0, 20).join("\n")
let output = "<file>\n"
output += content.join("\n")
if (lines.length > offset + content.length) {
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${offset + content.length})`
}
output += "\n</file>"
// just warms the lsp client
LSP.touchFile(filepath, false)
FileTime.read(ctx.sessionID, filepath)
return {
title: path.relative(Instance.worktree, filepath),
output,
metadata: {
preview,
},
}
},
})
function isImageFile(filePath: string): string | false {
const ext = path.extname(filePath).toLowerCase()
switch (ext) {
case ".jpg":
case ".jpeg":
return "JPEG"
case ".png":
return "PNG"
case ".gif":
return "GIF"
case ".bmp":
return "BMP"
case ".webp":
return "WebP"
default:
return false
}
}
async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise<boolean> {
const ext = path.extname(filepath).toLowerCase()
// binary check for common non-text extensions
switch (ext) {
case ".zip":
case ".tar":
case ".gz":
case ".exe":
case ".dll":
case ".so":
case ".class":
case ".jar":
case ".war":
case ".7z":
case ".doc":
case ".docx":
case ".xls":
case ".xlsx":
case ".ppt":
case ".pptx":
case ".odt":
case ".ods":
case ".odp":
case ".bin":
case ".dat":
case ".obj":
case ".o":
case ".a":
case ".lib":
case ".wasm":
case ".pyc":
case ".pyo":
return true
default:
break
}
const stat = await file.stat()
const fileSize = stat.size
if (fileSize === 0) return false
const bufferSize = Math.min(4096, fileSize)
const buffer = await file.arrayBuffer()
if (buffer.byteLength === 0) return false
const bytes = new Uint8Array(buffer.slice(0, bufferSize))
let nonPrintableCount = 0
for (let i = 0; i < bytes.length; i++) {
if (bytes[i] === 0) return true
if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
nonPrintableCount++
}
}
// If >30% non-printable characters, consider it binary
return nonPrintableCount / bytes.length > 0.3
}