feat(hashline): port hashline edit tool from oh-my-pi
This PR ports the hashline edit tool from oh-my-pi to oh-my-opencode as an experimental feature. ## Features - New experimental.hashline_edit config flag - hashline_edit tool with 4 operations: set_line, replace_lines, insert_after, replace - Hash-based line anchors for safe concurrent editing - Edit tool disabler for non-OpenAI providers - Read output enhancer with LINE:HASH prefixes - Provider state tracking module ## Technical Details - xxHash32-based 2-char hex hashes - Bottom-up edit application to prevent index shifting - OpenAI provider exemption (uses native apply_patch) - 90 tests covering all operations and edge cases - All files under 200 LOC limit ## Files Added/Modified - src/tools/hashline-edit/ (7 files, ~400 LOC) - src/hooks/hashline-edit-disabler/ (4 files, ~200 LOC) - src/hooks/hashline-read-enhancer/ (3 files, ~400 LOC) - src/features/hashline-provider-state.ts (13 LOC) - src/config/schema/experimental.ts (hashline_edit flag) - src/config/schema/hooks.ts (2 new hook names) - src/plugin/tool-registry.ts (conditional registration) - src/plugin/chat-params.ts (provider state tracking) - src/tools/index.ts (export) - src/hooks/index.ts (exports)
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { getProvider } from "../../features/hashline-provider-state"
|
||||
import { computeLineHash } from "../../tools/hashline-edit/hash-computation"
|
||||
|
||||
interface HashlineReadEnhancerConfig {
|
||||
hashline_edit?: { enabled: boolean }
|
||||
}
|
||||
|
||||
const READ_LINE_PATTERN = /^(\d+): (.*)$/
|
||||
|
||||
function isReadTool(toolName: string): boolean {
|
||||
return toolName.toLowerCase() === "read"
|
||||
}
|
||||
|
||||
function shouldProcess(sessionID: string, config: HashlineReadEnhancerConfig): boolean {
|
||||
if (!config.hashline_edit?.enabled) {
|
||||
return false
|
||||
}
|
||||
const providerID = getProvider(sessionID)
|
||||
if (providerID === "openai") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function isTextFile(output: string): boolean {
|
||||
const firstLine = output.split("\n")[0] ?? ""
|
||||
return READ_LINE_PATTERN.test(firstLine)
|
||||
}
|
||||
|
||||
function transformLine(line: string): string {
|
||||
const match = READ_LINE_PATTERN.exec(line)
|
||||
if (!match) {
|
||||
return line
|
||||
}
|
||||
const lineNumber = parseInt(match[1], 10)
|
||||
const content = match[2]
|
||||
const hash = computeLineHash(lineNumber, content)
|
||||
return `${lineNumber}:${hash}|${content}`
|
||||
}
|
||||
|
||||
function transformOutput(output: string): string {
|
||||
if (!output) {
|
||||
return output
|
||||
}
|
||||
if (!isTextFile(output)) {
|
||||
return output
|
||||
}
|
||||
const lines = output.split("\n")
|
||||
return lines.map(transformLine).join("\n")
|
||||
}
|
||||
|
||||
export function createHashlineReadEnhancerHook(
|
||||
_ctx: PluginInput,
|
||||
config: HashlineReadEnhancerConfig
|
||||
) {
|
||||
return {
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (!isReadTool(input.tool)) {
|
||||
return
|
||||
}
|
||||
if (typeof output.output !== "string") {
|
||||
return
|
||||
}
|
||||
if (!shouldProcess(input.sessionID, config)) {
|
||||
return
|
||||
}
|
||||
output.output = transformOutput(output.output)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { createHashlineReadEnhancerHook } from "./hook"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { setProvider, clearProvider } from "../../features/hashline-provider-state"
|
||||
|
||||
//#given - Test setup helpers
|
||||
function createMockContext(): PluginInput {
|
||||
return {
|
||||
client: {} as unknown as PluginInput["client"],
|
||||
directory: "/test",
|
||||
}
|
||||
}
|
||||
|
||||
interface TestConfig {
|
||||
hashline_edit?: { enabled: boolean }
|
||||
}
|
||||
|
||||
function createMockConfig(enabled: boolean): TestConfig {
|
||||
return {
|
||||
hashline_edit: { enabled },
|
||||
}
|
||||
}
|
||||
|
||||
describe("createHashlineReadEnhancerHook", () => {
|
||||
let mockCtx: PluginInput
|
||||
const sessionID = "test-session-123"
|
||||
|
||||
beforeEach(() => {
|
||||
mockCtx = createMockContext()
|
||||
clearProvider(sessionID)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearProvider(sessionID)
|
||||
})
|
||||
|
||||
describe("tool name matching", () => {
|
||||
it("should process 'read' tool (lowercase)", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||
const output = { title: "Read", output: "1: hello\n2: world", metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toContain("1:")
|
||||
expect(output.output).toContain("|")
|
||||
})
|
||||
|
||||
it("should process 'Read' tool (mixed case)", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "Read", sessionID, callID: "call-1" }
|
||||
const output = { title: "Read", output: "1: hello\n2: world", metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toContain("|")
|
||||
})
|
||||
|
||||
it("should process 'READ' tool (uppercase)", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "READ", sessionID, callID: "call-1" }
|
||||
const output = { title: "Read", output: "1: hello\n2: world", metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toContain("|")
|
||||
})
|
||||
|
||||
it("should skip non-read tools", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "edit", sessionID, callID: "call-1" }
|
||||
const originalOutput = "1: hello\n2: world"
|
||||
const output = { title: "Edit", output: originalOutput, metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
})
|
||||
})
|
||||
|
||||
describe("config flag check", () => {
|
||||
it("should skip when hashline_edit is disabled", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(false))
|
||||
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||
const originalOutput = "1: hello\n2: world"
|
||||
const output = { title: "Read", output: originalOutput, metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
})
|
||||
|
||||
it("should skip when hashline_edit config is missing", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, {})
|
||||
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||
const originalOutput = "1: hello\n2: world"
|
||||
const output = { title: "Read", output: originalOutput, metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
})
|
||||
})
|
||||
|
||||
describe("provider check", () => {
|
||||
it("should skip when provider is OpenAI", async () => {
|
||||
//#given
|
||||
setProvider(sessionID, "openai")
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||
const originalOutput = "1: hello\n2: world"
|
||||
const output = { title: "Read", output: originalOutput, metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
})
|
||||
|
||||
it("should process when provider is Claude", async () => {
|
||||
//#given
|
||||
setProvider(sessionID, "anthropic")
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||
const output = { title: "Read", output: "1: hello\n2: world", metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toContain("|")
|
||||
})
|
||||
|
||||
it("should process when provider is unknown (undefined)", async () => {
|
||||
//#given
|
||||
// Provider not set, getProvider returns undefined
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||
const output = { title: "Read", output: "1: hello\n2: world", metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toContain("|")
|
||||
})
|
||||
})
|
||||
|
||||
describe("output transformation", () => {
|
||||
it("should transform 'N: content' format to 'N:HASH|content'", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||
const output = { title: "Read", output: "1: function hello() {\n2: console.log('world')\n3: }", metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
const lines = output.output.split("\n")
|
||||
expect(lines[0]).toMatch(/^1:[a-f0-9]{2}\|function hello\(\) \{$/)
|
||||
expect(lines[1]).toMatch(/^2:[a-f0-9]{2}\| console\.log\('world'\)$/)
|
||||
expect(lines[2]).toMatch(/^3:[a-f0-9]{2}\|\}$/)
|
||||
})
|
||||
|
||||
it("should handle empty output", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||
const output = { title: "Read", output: "", metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe("")
|
||||
})
|
||||
|
||||
it("should handle single line", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||
const output = { title: "Read", output: "1: const x = 1", metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toMatch(/^1:[a-f0-9]{2}\|const x = 1$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("binary file detection", () => {
|
||||
it("should skip binary files (no line number prefix)", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||
const originalOutput = "PNG\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"
|
||||
const output = { title: "Read", output: originalOutput, metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
})
|
||||
|
||||
it("should skip if first line doesn't match pattern", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||
const originalOutput = "some binary data\nmore data"
|
||||
const output = { title: "Read", output: originalOutput, metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
})
|
||||
|
||||
it("should process if first line matches 'N: ' pattern", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||
const output = { title: "Read", output: "1: valid line\n2: another line", metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toContain("|")
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle non-string output gracefully", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||
const output = { title: "Read", output: null as unknown as string, metadata: {} }
|
||||
|
||||
//#when - should not throw
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle lines with no content after colon", async () => {
|
||||
//#given
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||
const output = { title: "Read", output: "1: hello\n2: \n3: world", metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
const lines = output.output.split("\n")
|
||||
expect(lines[0]).toMatch(/^1:[a-f0-9]{2}\|hello$/)
|
||||
expect(lines[1]).toMatch(/^2:[a-f0-9]{2}\|$/)
|
||||
expect(lines[2]).toMatch(/^3:[a-f0-9]{2}\|world$/)
|
||||
})
|
||||
|
||||
it("should handle very long lines", async () => {
|
||||
//#given
|
||||
const longContent = "a".repeat(1000)
|
||||
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
|
||||
const input = { tool: "read", sessionID, callID: "call-1" }
|
||||
const output = { title: "Read", output: `1: ${longContent}`, metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toMatch(/^1:[a-f0-9]{2}\|a+$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { createHashlineReadEnhancerHook } from "./hook"
|
||||
Reference in New Issue
Block a user