Skip to content

feat(hashline): Phase 3 — hashline_edit tool #212

@randomm

Description

@randomm

Context

Part of the hashline EPIC (#209). Depends on Phase 1 (#210) and Phase 2 (#211) being merged first. You need applyHashlineEdits and HashlineEdit from src/tool/hashline.ts, and the OPENCODE_EXPERIMENTAL_HASHLINE flag from Phase 2.

Read the EPIC (#209), Phase 1 (#210), and Phase 2 (#211) before starting.

What You Are Building

Three things:

  • packages/opencode/src/tool/hashline_edit.ts — the tool definition
  • packages/opencode/src/tool/hashline_edit.txt — the description shown to the model
  • A one-line export addition to edit.ts (see below)

Do NOT otherwise modify edit.ts, edit.txt, or any other existing tool.

Required Change to edit.ts

normalizeLineEndings is defined in edit.ts line 23 but is not exported. Add export:

// Change from:
function normalizeLineEndings(text: string): string {
// To:
export function normalizeLineEndings(text: string): string {

This is the only change to edit.ts. trimDiff is already exported at line 582.

Required Imports

import z from "zod"
import * as path from "path"
import { Tool } from "./tool"
import { Bus } from "../bus"
import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { FileTime } from "../file/time"
import { LSP } from "../lsp"
import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot"
import { Filesystem } from "../util/filesystem"
import { assertExternalDirectory } from "./external-directory"
import { createTwoFilesPatch, diffLines } from "diff"
import { trimDiff, normalizeLineEndings } from "./edit"
import { applyHashlineEdits } from "./hashline"
import type { HashlineEdit } from "./hashline"
import DESCRIPTION from "./hashline_edit.txt"

Note: HashlineMismatchError and HashlineNoOpError are NOT imported — they are never caught or referenced in this file. They propagate naturally as unhandled exceptions. Importing them without using them would cause a lint error.

Tool Schema

const AnchorSchema = z.string().regex(
  /^\d+[\u4E00-\u9FFF]$/,
  "Anchor must be a line number followed by a single CJK character, e.g. '14丐'"
)

const EditSchema = z.discriminatedUnion("op", [
  z.object({
    op: z.literal("set_line"),
    anchor: AnchorSchema.describe("The line to replace, e.g. '14丐'"),
    new_text: z.string().describe("Replacement text. Empty string deletes the line."),
  }),
  z.object({
    op: z.literal("replace_lines"),
    start_anchor: AnchorSchema.describe("First line of the range to replace"),
    end_anchor: AnchorSchema.describe("Last line of the range to replace (inclusive)"),
    new_text: z.string().describe("Replacement text. Empty string deletes the range."),
  }),
  z.object({
    op: z.literal("insert_after"),
    anchor: AnchorSchema.describe("Insert new lines after this line"),
    text: z.string().min(1).describe("Text to insert (must be non-empty)"),
  }),
])

const HashlineEditParams = z.object({
  filePath: z.string().describe("Absolute path to the file to edit"),
  edits: z.array(EditSchema).min(1).describe("List of edit operations to apply atomically"),
})

Tool Execute Logic

async execute(params, ctx) {
  const filePath = path.isAbsolute(params.filePath)
    ? params.filePath
    : path.join(Instance.directory, params.filePath)

  await assertExternalDirectory(ctx, filePath)

  let diff = ""
  let contentOld = ""
  let contentNew = ""

  await FileTime.withLock(filePath, async () => {
    const file = Bun.file(filePath)
    const stats = await file.stat().catch(() => {})
    if (!stats) throw new Error(`File ${filePath} not found`)
    if (stats.isDirectory()) throw new Error(`Path is a directory: ${filePath}`)

    // Throws if hashline_read was not called on this file in this session.
    await FileTime.assert(ctx.sessionID, filePath)

    contentOld = await file.text()

    // Synchronous — do NOT use await.
    // Throws HashlineMismatchError or HashlineNoOpError on failure.
    // Do NOT catch these — let them propagate through withLock (which re-throws via try/finally).
    // If either error is thrown, execution does NOT reach the code below the lock.
    // contentOld/contentNew remaining "" is irrelevant — those lines are never reached.
    contentNew = applyHashlineEdits(contentOld, params.edits)

    diff = trimDiff(
      createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew))
    )

    await ctx.ask({
      permission: "edit",
      patterns: [path.relative(Instance.worktree, filePath)],
      always: ["*"],
      metadata: { filepath: filePath, diff },
    })

    await file.write(contentNew)
    await Bus.publish(File.Event.Edited, { file: filePath })
    await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
    contentNew = await file.text()
    diff = trimDiff(
      createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew))
    )
    FileTime.read(ctx.sessionID, filePath)
  })

  // Only reached on success (no thrown errors above)
  const filediff: Snapshot.FileDiff = {
    file: filePath,
    before: contentOld,
    after: contentNew,
    additions: 0,
    deletions: 0,
  }
  for (const change of diffLines(contentOld, contentNew)) {
    if (change.added) filediff.additions += change.count || 0
    if (change.removed) filediff.deletions += change.count || 0
  }

  // Note the nested structure: { metadata: { ... } }
  ctx.metadata({
    metadata: {
      diff,
      filediff,
      diagnostics: {},
    },
  })

  let output = "Edit applied successfully."
  await LSP.touchFile(filePath, true)
  const diagnostics = await LSP.diagnostics()
  const normalizedFilePath = Filesystem.normalizePath(filePath)
  const issues = diagnostics[normalizedFilePath] ?? []
  const errors = issues.filter((item) => item.severity === 1)
  if (errors.length > 0) {
    const limited = errors.slice(0, 20)
    const suffix = errors.length > 20 ? `\n... and ${errors.length - 20} more` : ""
    output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
  }

  return {
    metadata: { diagnostics, diff, filediff },
    title: `${path.relative(Instance.worktree, filePath)}`,
    output,
  }
},

Tool Description File (hashline_edit.txt)

Write clear instructions for the model. Keep under 60 lines. Look at edit.txt for style.

Must include:

  1. Purpose: Applies precise line-addressed edits using CJK hash anchors from hashline_read. All edits applied atomically.
  2. Prerequisite: Call hashline_read first. Anchors from the regular read tool will NOT work.
  3. Three operations:
    • set_line: Replace single line. new_text: "" deletes the line.
    • replace_lines: Replace contiguous range (inclusive). new_text: "" deletes the range.
    • insert_after: Insert after anchor line. text must be non-empty.
  4. Anchor format: Copy LINENUM꜀ prefix verbatim from hashline_read output. Do NOT include line content. Example: hashline_read shows 42丐 return foo() → anchor is 42丐.
  5. Batching: All edits for a file in one call. Re-read with hashline_read before further edits.
  6. On hash mismatch: Error shows current file with updated anchors (changed lines prefixed ). Copy new anchors and retry.
  7. Example:
    // hashline_read output:
    3丒export function greet(name: string) {
    4专  return "hello"
    5且}
    // hashline_edit call:
    { "filePath": "/path/to/file.ts", "edits": [
      { "op": "set_line", "anchor": "4专", "new_text": "  return `hello, ${name}`" }
    ]}
    

Registry Wiring

Step 1 — Update the registry line added in Phase 2.

Find this line in registry.ts all() array (added by Phase 2):

// BEFORE (Phase 2 added this — find it and edit it):
...(Flag.OPENCODE_EXPERIMENTAL_HASHLINE ? [HashlineReadTool] : []),

// AFTER (replace the above line — do NOT add a new line):
...(Flag.OPENCODE_EXPERIMENTAL_HASHLINE ? [HashlineReadTool, HashlineEditTool] : []),

Do NOT add a second spread line — that would register HashlineReadTool twice.

Step 2 — Suppress edit (but NOT write) when hashline is active.

write is used for new file creation and full rewrites — hashline has no equivalent. Only edit is superseded. Update the existing filter in tools():

// Before (existing code):
if (t.id === "edit" || t.id === "write") return !usePatch

// After:
if (t.id === "edit") return !usePatch && !Flag.OPENCODE_EXPERIMENTAL_HASHLINE
if (t.id === "write") return !usePatch

Tests

Create file: packages/opencode/test/tool/hashline_edit.test.ts

Pattern: create temp file → call hashline_read execute to get valid anchors → call hashline_edit execute.

Required test cases:

  1. set_line replaces correct line; file on disk updated
  2. replace_lines replaces a range correctly
  3. insert_after inserts at correct position
  4. Multiple edits in one call all applied
  5. HashlineMismatchError propagates when anchor hash doesn't match
  6. HashlineNoOpError propagates when edits produce identical content
  7. Calling without prior hashline_read throws FileTime.assert error
  8. Non-existent file → clear File not found: error
  9. After successful edit, re-reading with hashline_read shows updated content
  10. Registry filtering: with process.env.OPENCODE_EXPERIMENTAL_HASHLINE = "1" set before accessing Flag, edit tool is absent from tools() result; write is still present. (Because Flag.OPENCODE_EXPERIMENTAL_HASHLINE is a dynamic getter added in Phase 2, setting process.env before reading it works correctly.)

File Conventions

  • Prefer const over let; avoid else — prefer early returns
  • No // TODO, // FIXME, as any, @ts-ignore
  • Max 500 lines per file

Acceptance Criteria

  • src/tool/hashline_edit.ts created, exports HashlineEditTool
  • src/tool/hashline_edit.txt created with clear model instructions
  • normalizeLineEndings exported from edit.ts (one-line change only)
  • HashlineEditTool registered in registry.ts all() (Phase 2 line updated, not duplicated)
  • When flag active: edit absent from tool list, write still present
  • All 10 tests pass: bun test test/tool/hashline_edit.test.ts
  • bun run typecheck passes with 0 errors
  • bun test passes with 0 failures (no regressions)
  • edit.ts modified only by adding export to normalizeLineEndings

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