Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export namespace Flag {
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL")
export declare const OPENCODE_EXPERIMENTAL_HASHLINE: boolean

// Experimental
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
Expand Down Expand Up @@ -139,12 +140,23 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
})

// Dynamic getter for OPENCODE_CLIENT
// This must be evaluated at access time, not module load time,
// because some commands override the client at runtime
Object.defineProperty(Flag, "OPENCODE_CLIENT", {
get() {
return process.env["OPENCODE_CLIENT"] ?? "cli"
},
enumerable: true,
configurable: false,
})
// This must be evaluated at access time, not module load time,
// because some commands override the client at runtime
Object.defineProperty(Flag, "OPENCODE_CLIENT", {
get() {
return process.env["OPENCODE_CLIENT"] ?? "cli"
},
enumerable: true,
configurable: false,
})

// Dynamic getter for OPENCODE_EXPERIMENTAL_HASHLINE
// This must be evaluated at access time, not module load time,
// to allow tests to control hashline features
Object.defineProperty(Flag, "OPENCODE_EXPERIMENTAL_HASHLINE", {
get() {
return truthy("OPENCODE_EXPERIMENTAL_HASHLINE")
},
enumerable: true,
configurable: false,
})
194 changes: 194 additions & 0 deletions packages/opencode/src/tool/hashline_read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
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 "./hashline_read.txt"
import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory"
import { InstructionPrompt } from "../session/instruction"
import { Flag } from "../flag/flag"
import { hashLine } from "./hashline"

const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
const MAX_BYTES = 50 * 1024

export const HashlineReadTool = Tool.define("hashline_read", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to read"),
offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(),
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
}),
async execute(params, ctx) {
if (params.offset !== undefined && params.offset < 1) {
throw new Error("offset must be greater than or equal to 1")
}
let filepath = params.filePath
if (!path.isAbsolute(filepath)) {
filepath = path.resolve(Instance.directory, filepath)
}
const title = path.relative(Instance.worktree, filepath)

const file = Bun.file(filepath)
const stat = await file.stat().catch(() => undefined)

await assertExternalDirectory(ctx, filepath, {
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
kind: stat?.isDirectory() ? "directory" : "file",
})

await ctx.ask({
permission: "read",
patterns: [filepath],
always: ["*"],
metadata: {},
})

if (!stat) {
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}`)
}

if (stat.isDirectory()) {
throw new Error(`Cannot read directory with hashline_read: ${filepath}`)
}

const instructions = await InstructionPrompt.resolve(ctx.messages, filepath, ctx.messageID)

const isBinary = await isBinaryFile(filepath, file)
if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`)

const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset ?? 1
const start = offset - 1
const lines = await file.text().then((text) => text.split("\n"))
if (start >= lines.length) throw new Error(`Offset ${offset} is out of range for this file (${lines.length} lines)`)

const raw: string[] = []
let bytes = 0
let truncatedByBytes = false
for (let i = start; i < Math.min(lines.length, start + limit); i++) {
const line = lines[i].length > MAX_LINE_LENGTH ? lines[i].substring(0, MAX_LINE_LENGTH) + "..." : lines[i]
const lineNum = i + 1
const hashChar = hashLine(line)
const outputLine = `${lineNum}${hashChar}${line}`
const size = Buffer.byteLength(outputLine, "utf-8") + (raw.length > 0 ? 1 : 0)
if (bytes + size > MAX_BYTES) {
truncatedByBytes = true
break
}
raw.push(outputLine)
bytes += size
}

const content = raw
const preview = raw.slice(0, 20).join("\n")

let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
output += content.join("\n")

const totalLines = lines.length
const lastReadLine = offset + raw.length - 1
const hasMoreLines = totalLines > lastReadLine
const truncated = hasMoreLines || truncatedByBytes

if (truncatedByBytes) {
output += `\n\n(Output truncated at ${MAX_BYTES} bytes. Use 'offset' parameter to read beyond line ${lastReadLine})`
} else if (hasMoreLines) {
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${lastReadLine})`
} else {
output += `\n\n(End of file - total ${totalLines} lines)`
}
output += "\n</content>"

LSP.touchFile(filepath, false)
FileTime.read(ctx.sessionID, filepath)

if (instructions.length > 0) {
output += `\n\n<system-reminder>\n${instructions.map((i) => i.content).join("\n\n")}\n</system-reminder>`
}

return {
title,
output,
metadata: {
preview,
truncated,
loaded: instructions.map((i) => i.filepath),
},
}
},
})

async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise<boolean> {
const ext = path.extname(filepath).toLowerCase()
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++
}
}
return nonPrintableCount / bytes.length > 0.3
}
10 changes: 10 additions & 0 deletions packages/opencode/src/tool/hashline_read.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Read a file and return its contents with each line prefixed by a content-addressable anchor.

Each line is formatted as: {lineNumber}{CJK_HASH_CHAR}{content}
Example: "14丐 const foo = 1"

The CJK hash character is derived from the line content using xxHash32. Use these line anchors
when calling hashline_edit — they allow the model to reference specific lines precisely even
after the file has changed.

Supports the same offset/limit/maxTokens parameters as the read tool.
2 changes: 2 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { ApplyPatchTool } from "./apply_patch"
import { CheckTaskTool } from "./check_task"
import { ListTasksTool } from "./list_tasks"
import { CancelTaskTool } from "./cancel_task"
import { HashlineReadTool } from "./hashline_read"

export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
Expand Down Expand Up @@ -130,6 +131,7 @@ export namespace ToolRegistry {
SkillTool,
ApplyPatchTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_HASHLINE ? [HashlineReadTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
...custom,
Expand Down
Loading