-
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) 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 definitionpackages/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:
- Purpose: Applies precise line-addressed edits using CJK hash anchors from
hashline_read. All edits applied atomically. - Prerequisite: Call
hashline_readfirst. Anchors from the regularreadtool will NOT work. - 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.textmust be non-empty.
- Anchor format: Copy
LINENUM꜀prefix verbatim fromhashline_readoutput. Do NOT include line content. Example:hashline_readshows42丐 return foo()→ anchor is42丐. - Batching: All edits for a file in one call. Re-read with
hashline_readbefore further edits. - On hash mismatch: Error shows current file with updated anchors (changed lines prefixed
→). Copy new anchors and retry. - 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 !usePatchTests
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:
set_linereplaces correct line; file on disk updatedreplace_linesreplaces a range correctlyinsert_afterinserts at correct position- Multiple edits in one call all applied
HashlineMismatchErrorpropagates when anchor hash doesn't matchHashlineNoOpErrorpropagates when edits produce identical content- Calling without prior
hashline_readthrowsFileTime.asserterror - Non-existent file → clear
File not found:error - After successful edit, re-reading with
hashline_readshows updated content - Registry filtering: with
process.env.OPENCODE_EXPERIMENTAL_HASHLINE = "1"set before accessingFlag,edittool is absent fromtools()result;writeis still present. (BecauseFlag.OPENCODE_EXPERIMENTAL_HASHLINEis a dynamic getter added in Phase 2, settingprocess.envbefore reading it works correctly.)
File Conventions
- Prefer
constoverlet; avoidelse— prefer early returns - No
// TODO,// FIXME,as any,@ts-ignore - Max 500 lines per file
Acceptance Criteria
-
src/tool/hashline_edit.tscreated, exportsHashlineEditTool -
src/tool/hashline_edit.txtcreated with clear model instructions -
normalizeLineEndingsexported fromedit.ts(one-line change only) -
HashlineEditToolregistered inregistry.tsall()(Phase 2 line updated, not duplicated) - When flag active:
editabsent from tool list,writestill present - All 10 tests pass:
bun test test/tool/hashline_edit.test.ts -
bun run typecheckpasses with 0 errors -
bun testpasses with 0 failures (no regressions) -
edit.tsmodified only by addingexporttonormalizeLineEndings
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