Skip to content

feat(hashline): Phase 2 — hashline_read tool #211

@randomm

Description

@randomm

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 definition
  • packages/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 * 1024

Execute 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 directorieshashline_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:

  1. Purpose: Reads a file showing each line with a CJK hash anchor for use with hashline_edit.
  2. Format: LINENUM꜀CONTENT where is a CJK character. Example:
    1丐import { foo } from "./foo"
    2丑
    3丒export function bar() {
    4专  return foo()
    5且}
    
  3. How to use anchors: Copy the full LINENUM꜀ prefix verbatim when calling hashline_edit. Do not include the line content in the anchor.
  4. Re-read after edit: After any hashline_edit, anchors change — re-read before further edits.
  5. On mismatch: If hashline_edit returns a hash mismatch error, copy the new anchors shown in the error and retry.
  6. 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: boolean

Then, 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:

  1. Reads a file and formats each line as LINENUM꜀CONTENT (no colon, no space between number and CJK char)
  2. CJK character has charCodeAt(0) in range [0x4E00, 0x9FFF]
  3. Same line content always produces the same CJK character (stability)
  4. offset parameter: line numbers in output reflect file position (line 5 outputs as 5꜀... not 1꜀...)
  5. limit caps number of lines returned
  6. Truncation footer appears when file has more lines than limit
  7. EOF footer appears when entire file is read
  8. Binary file rejected with Cannot read binary file: error
  9. Directory path rejected with Path is a directory, not a file: error
  10. Non-existent file rejected with File not found: error
  11. FileTime.read is called: after hashline_read, verify a subsequent hashline_edit does NOT throw FileTime.assert error (integration test)

File Conventions

  • Prefer const over let; 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.ts created, exports HashlineReadTool
  • src/tool/hashline_read.txt created with clear model instructions
  • OPENCODE_EXPERIMENTAL_HASHLINE added to src/flag/flag.ts as a dynamic getter
  • HashlineReadTool registered in registry.ts all() array behind the flag
  • All 11 tests pass: bun test test/tool/hashline_read.test.ts
  • bun run typecheck passes with 0 errors
  • bun test passes with 0 failures (no regressions)
  • read.ts and read.txt are completely unmodified

Quality Gates (Non-Negotiable)

  • TDD: Write failing tests first, then implement
  • Local verification: bun run typecheck && bun test pass before completion
  • No suppressions: No as any, @ts-ignore, // eslint-disable

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions