-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Context
Part of the hashline EPIC (#209). Depends on Phase 1 (#210) being merged first — you need hashLine from packages/opencode/src/tool/hashline.ts.
Read the EPIC (#209) and Phase 1 (#210) before starting.
What You Are Building
Two new files:
packages/opencode/src/tool/hashline_read.ts— the tool definitionpackages/opencode/src/tool/hashline_read.txt— the description shown to the model
Do NOT modify read.ts or read.txt. The existing read tool is completely unchanged.
The Output Format
The existing read tool outputs:
14: function hello() {
15: return "world"
hashline_read must output:
14丐function hello() {
15丑 return "world"
Format string per line: `${lineNumber}${hashLine(lineContent)}${lineContent}` — no space, no colon, no pipe.
Required Imports
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 { Instance } from "../project/instance"
import { InstructionPrompt } from "../session/instruction"
import { assertExternalDirectory } from "./external-directory"
import { hashLine } from "./hashline"
import DESCRIPTION from "./hashline_read.txt"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
const MAX_BYTES = 50 * 1024Execute Behaviour — In This Order
1. Resolve file path
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)2. Assert external directory (security — required)
await assertExternalDirectory(ctx, filepath, {
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
kind: stat?.isDirectory() ? "directory" : "file",
})Note: assertExternalDirectory options are optional — passing kind: undefined when stat is undefined is safe (it defaults to "file").
3. Request read permission (required — do not skip)
await ctx.ask({
permission: "read",
patterns: [filepath],
always: ["*"],
metadata: {},
})4. Handle missing file — copy the "Did you mean..." suggestion logic from read.ts lines 48–66 exactly.
5. Reject directories — hashline_read does NOT support directory listing:
if (stat!.isDirectory()) throw new Error(`Path is a directory, not a file: ${filepath}`)6. Resolve instruction prompts
const instructions = await InstructionPrompt.resolve(ctx.messages, filepath, ctx.messageID)7. Handle image/PDF — copy the image/PDF attachment return branch from read.ts lines 113–135 exactly (including the loaded: instructions.map(...) in its metadata).
8. Reject binary files — copy the isBinaryFile function from read.ts lines 202+ directly into hashline_read.ts. It is NOT exported from read.ts so you cannot import it. Copy the entire function body. If you want to avoid duplication you may extract it to a new shared file src/tool/binary.ts, but this is optional.
const isBinary = await isBinaryFile(filepath, file)
if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`)9. Read and format lines
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 lineNumber = i + 1
// IMPORTANT: measure the OUTPUT line size (including lineNumber + CJK char prefix),
// not just the raw line. CJK chars are 3 bytes each in UTF-8.
const outputLine = `${lineNumber}${hashLine(line)}${line}`
const size = Buffer.byteLength(outputLine, "utf-8") + (raw.length > 0 ? 1 : 0)
if (bytes + size > MAX_BYTES) { truncatedByBytes = true; break }
raw.push(line)
bytes += size
}
// Format: lineNumber + CJK hash char + raw line content, no separators
const content = raw.map((line, index) => {
const lineNumber = index + offset
return `${lineNumber}${hashLine(line)}${line}`
})10. Build output string — copy XML wrapper and footers from read.ts lines 165–180 exactly (<path>, <type>, <content> tags, truncation/EOF footers, closing </content>).
11. Warm LSP and record file read (required — hashline_edit depends on FileTime.read being called here)
LSP.touchFile(filepath, false)
FileTime.read(ctx.sessionID, filepath)12. Append instruction prompts
if (instructions.length > 0) {
output += `\n\n<system-reminder>\n${instructions.map((i) => i.content).join("\n\n")}\n</system-reminder>`
}13. Return
return {
title,
output,
metadata: {
preview: raw.slice(0, 20).join("\n"),
truncated: hasMoreLines || truncatedByBytes,
loaded: instructions.map((i) => i.filepath),
},
}Tool Description File (hashline_read.txt)
Write clear instructions for the model. Keep under 40 lines. Look at read.txt for style.
Must include:
- Purpose: Reads a file showing each line with a CJK hash anchor for use with
hashline_edit. - Format:
LINENUM꜀CONTENTwhere꜀is a CJK character. Example:1丐import { foo } from "./foo" 2丑 3丒export function bar() { 4专 return foo() 5且} - How to use anchors: Copy the full
LINENUM꜀prefix verbatim when callinghashline_edit. Do not include the line content in the anchor. - Re-read after edit: After any
hashline_edit, anchors change — re-read before further edits. - On mismatch: If
hashline_editreturns a hash mismatch error, copy the new anchors shown in the error and retry. - Parameters:
filePath(required),offset(optional, 1-indexed),limit(optional, default 2000).
Registry and Flag Wiring
Step 1 — Add flag to src/flag/flag.ts
Add as a dynamic getter (NOT a const) so tests can toggle it via process.env. Place after the other experimental flags, following the same pattern as OPENCODE_DISABLE_CLAUDE_CODE (line 67):
First, add the TypeScript declaration inside the Flag namespace:
export declare const OPENCODE_EXPERIMENTAL_HASHLINE: booleanThen, after the namespace closing brace, add the dynamic getter:
Object.defineProperty(Flag, "OPENCODE_EXPERIMENTAL_HASHLINE", {
get() {
return Flag.OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_HASHLINE")
},
enumerable: true,
configurable: false,
})This means OPENCODE_EXPERIMENTAL=1 enables all experimental features, or OPENCODE_EXPERIMENTAL_HASHLINE=1 enables only hashline. And tests can set process.env.OPENCODE_EXPERIMENTAL_HASHLINE = "1" to toggle it.
Step 2 — Register tool in registry.ts in the all() array (around line 134, near the LSP tool):
...(Flag.OPENCODE_EXPERIMENTAL_HASHLINE ? [HashlineReadTool] : []),Do NOT add a filter condition in the tools() function's .filter() callback — feature flags belong in all(), not in model-conditional filters.
Note: Phase 3 will update this line to add HashlineEditTool. When implementing Phase 3, edit this line rather than adding a second spread.
Tests
Create file: packages/opencode/test/tool/hashline_read.test.ts
Use the tmpdir() fixture from test/fixture/fixture.ts and Instance.provide() wrapper. Look at existing tool tests for the setup pattern.
Required test cases:
- Reads a file and formats each line as
LINENUM꜀CONTENT(no colon, no space between number and CJK char) - CJK character has
charCodeAt(0)in range[0x4E00, 0x9FFF] - Same line content always produces the same CJK character (stability)
offsetparameter: line numbers in output reflect file position (line 5 outputs as5꜀...not1꜀...)limitcaps number of lines returned- Truncation footer appears when file has more lines than limit
- EOF footer appears when entire file is read
- Binary file rejected with
Cannot read binary file:error - Directory path rejected with
Path is a directory, not a file:error - Non-existent file rejected with
File not found:error FileTime.readis called: afterhashline_read, verify a subsequenthashline_editdoes NOT throwFileTime.asserterror (integration test)
File Conventions
- Prefer
constoverlet; use ternary/early returns instead of reassignment - Avoid
else— prefer early returns - No
// TODO,// FIXME,as any,@ts-ignore - Max 500 lines per file
Acceptance Criteria
-
src/tool/hashline_read.tscreated, exportsHashlineReadTool -
src/tool/hashline_read.txtcreated with clear model instructions -
OPENCODE_EXPERIMENTAL_HASHLINEadded tosrc/flag/flag.tsas a dynamic getter -
HashlineReadToolregistered inregistry.tsall()array behind the flag - All 11 tests pass:
bun test test/tool/hashline_read.test.ts -
bun run typecheckpasses with 0 errors -
bun testpasses with 0 failures (no regressions) -
read.tsandread.txtare completely unmodified
Quality Gates (Non-Negotiable)
- TDD: Write failing tests first, then implement
- Local verification:
bun run typecheck && bun testpass before completion - No suppressions: No
as any,@ts-ignore,// eslint-disable