diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index c443ff7e2..052956403 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -2830,6 +2830,9 @@ }, "safe_hook_creation": { "type": "boolean" + }, + "hashline_edit": { + "type": "boolean" } }, "additionalProperties": false diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 2d151ec53..448219d61 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -663,6 +663,59 @@ describe("ExperimentalConfigSchema feature flags", () => { expect(result.data.safe_hook_creation).toBeUndefined() } }) + + test("accepts hashline_edit as true", () => { + //#given + const config = { hashline_edit: true } + + //#when + const result = ExperimentalConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.hashline_edit).toBe(true) + } + }) + + test("accepts hashline_edit as false", () => { + //#given + const config = { hashline_edit: false } + + //#when + const result = ExperimentalConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.hashline_edit).toBe(false) + } + }) + + test("hashline_edit is optional", () => { + //#given + const config = { safe_hook_creation: true } + + //#when + const result = ExperimentalConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.hashline_edit).toBeUndefined() + } + }) + + test("rejects non-boolean hashline_edit", () => { + //#given + const config = { hashline_edit: "true" } + + //#when + const result = ExperimentalConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(false) + }) }) describe("GitMasterConfigSchema", () => { diff --git a/src/config/schema/experimental.ts b/src/config/schema/experimental.ts index 52747aae9..927a13a28 100644 --- a/src/config/schema/experimental.ts +++ b/src/config/schema/experimental.ts @@ -15,6 +15,8 @@ export const ExperimentalConfigSchema = z.object({ plugin_load_timeout_ms: z.number().min(1000).optional(), /** Wrap hook creation in try/catch to prevent one failing hook from crashing the plugin (default: true at call site) */ safe_hook_creation: z.boolean().optional(), + /** Enable hashline_edit tool for improved file editing with hash-based line anchors */ + hashline_edit: z.boolean().optional(), }) export type ExperimentalConfig = z.infer diff --git a/src/features/hashline-provider-state.test.ts b/src/features/hashline-provider-state.test.ts new file mode 100644 index 000000000..d8eb272ff --- /dev/null +++ b/src/features/hashline-provider-state.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import { setProvider, getProvider, clearProvider } from "./hashline-provider-state" + +describe("hashline-provider-state", () => { + beforeEach(() => { + // Clear state before each test + clearProvider("test-session-1") + clearProvider("test-session-2") + }) + + describe("setProvider", () => { + test("should store provider ID for a session", () => { + // given + const sessionID = "test-session-1" + const providerID = "openai" + + // when + setProvider(sessionID, providerID) + + // then + expect(getProvider(sessionID)).toBe("openai") + }) + + test("should overwrite existing provider for same session", () => { + // given + const sessionID = "test-session-1" + setProvider(sessionID, "openai") + + // when + setProvider(sessionID, "anthropic") + + // then + expect(getProvider(sessionID)).toBe("anthropic") + }) + }) + + describe("getProvider", () => { + test("should return undefined for non-existent session", () => { + // given + const sessionID = "non-existent-session" + + // when + const result = getProvider(sessionID) + + // then + expect(result).toBeUndefined() + }) + + test("should return stored provider ID", () => { + // given + const sessionID = "test-session-1" + setProvider(sessionID, "anthropic") + + // when + const result = getProvider(sessionID) + + // then + expect(result).toBe("anthropic") + }) + + test("should handle multiple sessions independently", () => { + // given + setProvider("session-1", "openai") + setProvider("session-2", "anthropic") + + // when + const result1 = getProvider("session-1") + const result2 = getProvider("session-2") + + // then + expect(result1).toBe("openai") + expect(result2).toBe("anthropic") + }) + }) + + describe("clearProvider", () => { + test("should remove provider for a session", () => { + // given + const sessionID = "test-session-1" + setProvider(sessionID, "openai") + + // when + clearProvider(sessionID) + + // then + expect(getProvider(sessionID)).toBeUndefined() + }) + + test("should not affect other sessions", () => { + // given + setProvider("session-1", "openai") + setProvider("session-2", "anthropic") + + // when + clearProvider("session-1") + + // then + expect(getProvider("session-1")).toBeUndefined() + expect(getProvider("session-2")).toBe("anthropic") + }) + + test("should handle clearing non-existent session gracefully", () => { + // given + const sessionID = "non-existent" + + // when + clearProvider(sessionID) + + // then + expect(getProvider(sessionID)).toBeUndefined() + }) + }) +}) diff --git a/src/features/hashline-provider-state.ts b/src/features/hashline-provider-state.ts new file mode 100644 index 000000000..5c04a730f --- /dev/null +++ b/src/features/hashline-provider-state.ts @@ -0,0 +1,13 @@ +const providerStateMap = new Map() + +export function setProvider(sessionID: string, providerID: string): void { + providerStateMap.set(sessionID, providerID) +} + +export function getProvider(sessionID: string): string | undefined { + return providerStateMap.get(sessionID) +} + +export function clearProvider(sessionID: string): void { + providerStateMap.delete(sessionID) +} diff --git a/src/tools/hashline-edit/constants.ts b/src/tools/hashline-edit/constants.ts new file mode 100644 index 000000000..17638a49a --- /dev/null +++ b/src/tools/hashline-edit/constants.ts @@ -0,0 +1,30 @@ +export const HASH_DICT = [ + "00", "01", "02", "03", "04", "05", "06", "07", "08", "09", + "0a", "0b", "0c", "0d", "0e", "0f", "10", "11", "12", "13", + "14", "15", "16", "17", "18", "19", "1a", "1b", "1c", "1d", + "1e", "1f", "20", "21", "22", "23", "24", "25", "26", "27", + "28", "29", "2a", "2b", "2c", "2d", "2e", "2f", "30", "31", + "32", "33", "34", "35", "36", "37", "38", "39", "3a", "3b", + "3c", "3d", "3e", "3f", "40", "41", "42", "43", "44", "45", + "46", "47", "48", "49", "4a", "4b", "4c", "4d", "4e", "4f", + "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", + "5a", "5b", "5c", "5d", "5e", "5f", "60", "61", "62", "63", + "64", "65", "66", "67", "68", "69", "6a", "6b", "6c", "6d", + "6e", "6f", "70", "71", "72", "73", "74", "75", "76", "77", + "78", "79", "7a", "7b", "7c", "7d", "7e", "7f", "80", "81", + "82", "83", "84", "85", "86", "87", "88", "89", "8a", "8b", + "8c", "8d", "8e", "8f", "90", "91", "92", "93", "94", "95", + "96", "97", "98", "99", "9a", "9b", "9c", "9d", "9e", "9f", + "a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", + "aa", "ab", "ac", "ad", "ae", "af", "b0", "b1", "b2", "b3", + "b4", "b5", "b6", "b7", "b8", "b9", "ba", "bb", "bc", "bd", + "be", "bf", "c0", "c1", "c2", "c3", "c4", "c5", "c6", "c7", + "c8", "c9", "ca", "cb", "cc", "cd", "ce", "cf", "d0", "d1", + "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9", "da", "db", + "dc", "dd", "de", "df", "e0", "e1", "e2", "e3", "e4", "e5", + "e6", "e7", "e8", "e9", "ea", "eb", "ec", "ed", "ee", "ef", + "f0", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", + "fa", "fb", "fc", "fd", "fe", "ff", +] as const + +export const HASHLINE_PATTERN = /^(\d+):([0-9a-f]{2})\|(.*)$/ diff --git a/src/tools/hashline-edit/hash-computation.test.ts b/src/tools/hashline-edit/hash-computation.test.ts new file mode 100644 index 000000000..8a0790357 --- /dev/null +++ b/src/tools/hashline-edit/hash-computation.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from "bun:test" +import { computeLineHash, formatHashLine, formatHashLines } from "./hash-computation" + +describe("computeLineHash", () => { + it("returns consistent 2-char hex for same input", () => { + //#given + const lineNumber = 1 + const content = "function hello() {" + + //#when + const hash1 = computeLineHash(lineNumber, content) + const hash2 = computeLineHash(lineNumber, content) + + //#then + expect(hash1).toBe(hash2) + expect(hash1).toMatch(/^[0-9a-f]{2}$/) + }) + + it("strips whitespace before hashing", () => { + //#given + const lineNumber = 1 + const content1 = "function hello() {" + const content2 = " function hello() { " + + //#when + const hash1 = computeLineHash(lineNumber, content1) + const hash2 = computeLineHash(lineNumber, content2) + + //#then + expect(hash1).toBe(hash2) + }) + + it("handles empty lines", () => { + //#given + const lineNumber = 1 + const content = "" + + //#when + const hash = computeLineHash(lineNumber, content) + + //#then + expect(hash).toMatch(/^[0-9a-f]{2}$/) + }) + + it("returns different hashes for different content", () => { + //#given + const lineNumber = 1 + const content1 = "function hello() {" + const content2 = "function world() {" + + //#when + const hash1 = computeLineHash(lineNumber, content1) + const hash2 = computeLineHash(lineNumber, content2) + + //#then + expect(hash1).not.toBe(hash2) + }) +}) + +describe("formatHashLine", () => { + it("formats line with hash prefix", () => { + //#given + const lineNumber = 42 + const content = "function hello() {" + + //#when + const result = formatHashLine(lineNumber, content) + + //#then + expect(result).toMatch(/^42:[0-9a-f]{2}\|function hello\(\) \{$/) + }) + + it("preserves content after hash prefix", () => { + //#given + const lineNumber = 1 + const content = "const x = 42" + + //#when + const result = formatHashLine(lineNumber, content) + + //#then + expect(result).toContain("|const x = 42") + }) +}) + +describe("formatHashLines", () => { + it("formats all lines with hash prefixes", () => { + //#given + const content = "function hello() {\n return 42\n}" + + //#when + const result = formatHashLines(content) + + //#then + const lines = result.split("\n") + expect(lines).toHaveLength(3) + expect(lines[0]).toMatch(/^1:[0-9a-f]{2}\|/) + expect(lines[1]).toMatch(/^2:[0-9a-f]{2}\|/) + expect(lines[2]).toMatch(/^3:[0-9a-f]{2}\|/) + }) + + it("handles empty file", () => { + //#given + const content = "" + + //#when + const result = formatHashLines(content) + + //#then + expect(result).toBe("") + }) + + it("handles single line", () => { + //#given + const content = "const x = 42" + + //#when + const result = formatHashLines(content) + + //#then + expect(result).toMatch(/^1:[0-9a-f]{2}\|const x = 42$/) + }) +}) diff --git a/src/tools/hashline-edit/hash-computation.ts b/src/tools/hashline-edit/hash-computation.ts new file mode 100644 index 000000000..348cf3ea2 --- /dev/null +++ b/src/tools/hashline-edit/hash-computation.ts @@ -0,0 +1,19 @@ +import { HASH_DICT } from "./constants" + +export function computeLineHash(lineNumber: number, content: string): string { + const stripped = content.replace(/\s+/g, "") + const hash = Bun.hash.xxHash32(stripped) + const index = hash % 256 + return HASH_DICT[index] +} + +export function formatHashLine(lineNumber: number, content: string): string { + const hash = computeLineHash(lineNumber, content) + return `${lineNumber}:${hash}|${content}` +} + +export function formatHashLines(content: string): string { + if (!content) return "" + const lines = content.split("\n") + return lines.map((line, index) => formatHashLine(index + 1, line)).join("\n") +} diff --git a/src/tools/hashline-edit/index.ts b/src/tools/hashline-edit/index.ts new file mode 100644 index 000000000..6c12810f2 --- /dev/null +++ b/src/tools/hashline-edit/index.ts @@ -0,0 +1,5 @@ +export { computeLineHash, formatHashLine, formatHashLines } from "./hash-computation" +export { parseLineRef, validateLineRef } from "./validation" +export type { LineRef } from "./validation" +export type { SetLine, ReplaceLines, InsertAfter, Replace, HashlineEdit } from "./types" +export { HASH_DICT, HASHLINE_PATTERN } from "./constants" diff --git a/src/tools/hashline-edit/types.ts b/src/tools/hashline-edit/types.ts new file mode 100644 index 000000000..3de342b1a --- /dev/null +++ b/src/tools/hashline-edit/types.ts @@ -0,0 +1,26 @@ +export interface SetLine { + type: "set_line" + line: string + text: string +} + +export interface ReplaceLines { + type: "replace_lines" + start_line: string + end_line: string + text: string +} + +export interface InsertAfter { + type: "insert_after" + line: string + text: string +} + +export interface Replace { + type: "replace" + old_text: string + new_text: string +} + +export type HashlineEdit = SetLine | ReplaceLines | InsertAfter | Replace diff --git a/src/tools/hashline-edit/validation.test.ts b/src/tools/hashline-edit/validation.test.ts new file mode 100644 index 000000000..b7ac1ab69 --- /dev/null +++ b/src/tools/hashline-edit/validation.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "bun:test" +import { parseLineRef, validateLineRef } from "./validation" + +describe("parseLineRef", () => { + it("parses valid line reference", () => { + //#given + const ref = "42:a3" + + //#when + const result = parseLineRef(ref) + + //#then + expect(result).toEqual({ line: 42, hash: "a3" }) + }) + + it("parses line reference with different hash", () => { + //#given + const ref = "1:ff" + + //#when + const result = parseLineRef(ref) + + //#then + expect(result).toEqual({ line: 1, hash: "ff" }) + }) + + it("throws on invalid format - no colon", () => { + //#given + const ref = "42a3" + + //#when & #then + expect(() => parseLineRef(ref)).toThrow() + }) + + it("throws on invalid format - non-numeric line", () => { + //#given + const ref = "abc:a3" + + //#when & #then + expect(() => parseLineRef(ref)).toThrow() + }) + + it("throws on invalid format - invalid hash", () => { + //#given + const ref = "42:xyz" + + //#when & #then + expect(() => parseLineRef(ref)).toThrow() + }) + + it("throws on empty string", () => { + //#given + const ref = "" + + //#when & #then + expect(() => parseLineRef(ref)).toThrow() + }) +}) + +describe("validateLineRef", () => { + it("validates matching hash", () => { + //#given + const lines = ["function hello() {", " return 42", "}"] + const ref = "1:42" + + //#when & #then + expect(() => validateLineRef(lines, ref)).not.toThrow() + }) + + it("throws on hash mismatch", () => { + //#given + const lines = ["function hello() {", " return 42", "}"] + const ref = "1:00" // Wrong hash + + //#when & #then + expect(() => validateLineRef(lines, ref)).toThrow() + }) + + it("throws on line out of bounds", () => { + //#given + const lines = ["function hello() {", " return 42", "}"] + const ref = "99:a3" + + //#when & #then + expect(() => validateLineRef(lines, ref)).toThrow() + }) + + it("throws on invalid line number", () => { + //#given + const lines = ["function hello() {"] + const ref = "0:a3" // Line numbers start at 1 + + //#when & #then + expect(() => validateLineRef(lines, ref)).toThrow() + }) + + it("error message includes current hash", () => { + //#given + const lines = ["function hello() {"] + const ref = "1:00" + + //#when & #then + expect(() => validateLineRef(lines, ref)).toThrow(/current hash/) + }) +}) diff --git a/src/tools/hashline-edit/validation.ts b/src/tools/hashline-edit/validation.ts new file mode 100644 index 000000000..178f35d8e --- /dev/null +++ b/src/tools/hashline-edit/validation.ts @@ -0,0 +1,39 @@ +import { computeLineHash } from "./hash-computation" + +export interface LineRef { + line: number + hash: string +} + +export function parseLineRef(ref: string): LineRef { + const match = ref.match(/^(\d+):([0-9a-f]{2})$/) + if (!match) { + throw new Error( + `Invalid line reference format: "${ref}". Expected format: "LINE:HASH" (e.g., "42:a3")` + ) + } + return { + line: Number.parseInt(match[1], 10), + hash: match[2], + } +} + +export function validateLineRef(lines: string[], ref: string): void { + const { line, hash } = parseLineRef(ref) + + if (line < 1 || line > lines.length) { + throw new Error( + `Line number ${line} out of bounds. File has ${lines.length} lines.` + ) + } + + const content = lines[line - 1] + const currentHash = computeLineHash(line, content) + + if (currentHash !== hash) { + throw new Error( + `Hash mismatch at line ${line}. Expected hash: ${hash}, current hash: ${currentHash}. ` + + `Line content may have changed. Current content: "${content}"` + ) + } +}