diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index cf3d5009..f769d5e1 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -48,6 +48,7 @@ export const HookNameSchema = z.enum([ "write-existing-file-guard", "anthropic-effort", "hashline-read-enhancer", + "hashline-edit-diff-enhancer", ]) export type HookName = z.infer diff --git a/src/hooks/hashline-edit-diff-enhancer/hook.ts b/src/hooks/hashline-edit-diff-enhancer/hook.ts new file mode 100644 index 00000000..94c001b0 --- /dev/null +++ b/src/hooks/hashline-edit-diff-enhancer/hook.ts @@ -0,0 +1,129 @@ +import { log } from "../../shared" + +interface HashlineEditDiffEnhancerConfig { + hashline_edit?: { enabled: boolean } +} + +type BeforeInput = { tool: string; sessionID: string; callID: string } +type BeforeOutput = { args: Record } +type AfterInput = { tool: string; sessionID: string; callID: string } +type AfterOutput = { title: string; output: string; metadata: Record } + +const STALE_TIMEOUT_MS = 5 * 60 * 1000 + +const pendingCaptures = new Map() + +function makeKey(sessionID: string, callID: string): string { + return `${sessionID}:${callID}` +} + +function cleanupStaleEntries(): void { + const now = Date.now() + for (const [key, entry] of pendingCaptures) { + if (now - entry.storedAt > STALE_TIMEOUT_MS) { + pendingCaptures.delete(key) + } + } +} + +function isEditTool(toolName: string): boolean { + return toolName === "edit" +} + +function countLineDiffs(oldContent: string, newContent: string): { additions: number; deletions: number } { + const oldLines = oldContent.split("\n") + const newLines = newContent.split("\n") + + const oldSet = new Map() + for (const line of oldLines) { + oldSet.set(line, (oldSet.get(line) ?? 0) + 1) + } + + const newSet = new Map() + for (const line of newLines) { + newSet.set(line, (newSet.get(line) ?? 0) + 1) + } + + let deletions = 0 + for (const [line, count] of oldSet) { + const newCount = newSet.get(line) ?? 0 + if (count > newCount) { + deletions += count - newCount + } + } + + let additions = 0 + for (const [line, count] of newSet) { + const oldCount = oldSet.get(line) ?? 0 + if (count > oldCount) { + additions += count - oldCount + } + } + + return { additions, deletions } +} + +async function captureOldContent(filePath: string): Promise { + try { + const file = Bun.file(filePath) + if (await file.exists()) { + return await file.text() + } + } catch { + log("[hashline-edit-diff-enhancer] failed to read old content", { filePath }) + } + return "" +} + +export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhancerConfig) { + const enabled = config.hashline_edit?.enabled ?? false + + return { + "tool.execute.before": async (input: BeforeInput, output: BeforeOutput) => { + if (!enabled || !isEditTool(input.tool)) return + + const filePath = typeof output.args.path === "string" ? output.args.path : undefined + if (!filePath) return + + cleanupStaleEntries() + const oldContent = await captureOldContent(filePath) + pendingCaptures.set(makeKey(input.sessionID, input.callID), { + content: oldContent, + filePath, + storedAt: Date.now(), + }) + }, + + "tool.execute.after": async (input: AfterInput, output: AfterOutput) => { + if (!enabled || !isEditTool(input.tool)) return + + const key = makeKey(input.sessionID, input.callID) + const captured = pendingCaptures.get(key) + if (!captured) return + pendingCaptures.delete(key) + + const { content: oldContent, filePath } = captured + + let newContent: string + try { + newContent = await Bun.file(filePath).text() + } catch { + log("[hashline-edit-diff-enhancer] failed to read new content", { filePath }) + return + } + + const { additions, deletions } = countLineDiffs(oldContent, newContent) + + output.metadata.filediff = { + file: filePath, + path: filePath, + before: oldContent, + after: newContent, + additions, + deletions, + } + + output.title = filePath + }, + } +} diff --git a/src/hooks/hashline-edit-diff-enhancer/index.test.ts b/src/hooks/hashline-edit-diff-enhancer/index.test.ts new file mode 100644 index 00000000..3836b033 --- /dev/null +++ b/src/hooks/hashline-edit-diff-enhancer/index.test.ts @@ -0,0 +1,185 @@ +import { describe, test, expect, beforeEach } from "bun:test" +import { createHashlineEditDiffEnhancerHook } from "./hook" + +function makeInput(tool: string, callID = "call-1", sessionID = "ses-1") { + return { tool, sessionID, callID } +} + +function makeBeforeOutput(args: Record) { + return { args } +} + +function makeAfterOutput(overrides?: Partial<{ title: string; output: string; metadata: Record }>) { + return { + title: overrides?.title ?? "", + output: overrides?.output ?? "Successfully applied 1 edit(s)", + metadata: overrides?.metadata ?? { truncated: false }, + } +} + +describe("hashline-edit-diff-enhancer", () => { + let hook: ReturnType + + beforeEach(() => { + hook = createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: true } }) + }) + + describe("tool.execute.before", () => { + test("captures old file content for edit tool", async () => { + const filePath = import.meta.dir + "/index.test.ts" + const input = makeInput("edit") + const output = makeBeforeOutput({ path: filePath, edits: [] }) + + await hook["tool.execute.before"](input, output) + + // given the hook ran without error, the old content should be stored internally + // we verify in the after hook test that it produces filediff + }) + + test("ignores non-edit tools", async () => { + const input = makeInput("read") + const output = makeBeforeOutput({ path: "/some/file.ts" }) + + // when - should not throw + await hook["tool.execute.before"](input, output) + }) + }) + + describe("tool.execute.after", () => { + test("injects filediff metadata after edit tool execution", async () => { + // given - a temp file that we can modify between before/after + const tmpDir = (await import("os")).tmpdir() + const tmpFile = `${tmpDir}/hashline-diff-test-${Date.now()}.ts` + const oldContent = "line 1\nline 2\nline 3\n" + await Bun.write(tmpFile, oldContent) + + const input = makeInput("edit", "call-diff-1") + const beforeOutput = makeBeforeOutput({ path: tmpFile, edits: [] }) + + // when - before hook captures old content + await hook["tool.execute.before"](input, beforeOutput) + + // when - file is modified (simulating hashline edit execution) + const newContent = "line 1\nmodified line 2\nline 3\nnew line 4\n" + await Bun.write(tmpFile, newContent) + + // when - after hook computes filediff + const afterOutput = makeAfterOutput() + await hook["tool.execute.after"](input, afterOutput) + + // then - metadata should contain filediff + const filediff = afterOutput.metadata.filediff as { + file: string + path: string + before: string + after: string + additions: number + deletions: number + } + expect(filediff).toBeDefined() + expect(filediff.file).toBe(tmpFile) + expect(filediff.path).toBe(tmpFile) + expect(filediff.before).toBe(oldContent) + expect(filediff.after).toBe(newContent) + expect(filediff.additions).toBeGreaterThan(0) + expect(filediff.deletions).toBeGreaterThan(0) + + // then - title should be set to the file path + expect(afterOutput.title).toBe(tmpFile) + + // cleanup + await Bun.file(tmpFile).exists() && (await import("fs/promises")).unlink(tmpFile) + }) + + test("does nothing for non-edit tools", async () => { + const input = makeInput("read", "call-other") + const afterOutput = makeAfterOutput() + const originalMetadata = { ...afterOutput.metadata } + + await hook["tool.execute.after"](input, afterOutput) + + // then - metadata unchanged + expect(afterOutput.metadata).toEqual(originalMetadata) + }) + + test("does nothing when no before capture exists", async () => { + // given - no before hook was called for this callID + const input = makeInput("edit", "call-no-before") + const afterOutput = makeAfterOutput() + const originalMetadata = { ...afterOutput.metadata } + + await hook["tool.execute.after"](input, afterOutput) + + // then - metadata unchanged (no filediff injected) + expect(afterOutput.metadata.filediff).toBeUndefined() + }) + + test("cleans up stored content after consumption", async () => { + const tmpDir = (await import("os")).tmpdir() + const tmpFile = `${tmpDir}/hashline-diff-cleanup-${Date.now()}.ts` + await Bun.write(tmpFile, "original") + + const input = makeInput("edit", "call-cleanup") + await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile })) + await Bun.write(tmpFile, "modified") + + // when - first after call consumes + const afterOutput1 = makeAfterOutput() + await hook["tool.execute.after"](input, afterOutput1) + expect(afterOutput1.metadata.filediff).toBeDefined() + + // when - second after call finds nothing + const afterOutput2 = makeAfterOutput() + await hook["tool.execute.after"](input, afterOutput2) + expect(afterOutput2.metadata.filediff).toBeUndefined() + + await (await import("fs/promises")).unlink(tmpFile).catch(() => {}) + }) + + test("handles file creation (empty old content)", async () => { + const tmpDir = (await import("os")).tmpdir() + const tmpFile = `${tmpDir}/hashline-diff-create-${Date.now()}.ts` + + // given - file doesn't exist during before hook + const input = makeInput("edit", "call-create") + await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile })) + + // when - file created during edit + await Bun.write(tmpFile, "new content\n") + + const afterOutput = makeAfterOutput() + await hook["tool.execute.after"](input, afterOutput) + + // then - filediff shows creation (before is empty) + const filediff = afterOutput.metadata.filediff as any + expect(filediff).toBeDefined() + expect(filediff.before).toBe("") + expect(filediff.after).toBe("new content\n") + expect(filediff.additions).toBeGreaterThan(0) + expect(filediff.deletions).toBe(0) + + await (await import("fs/promises")).unlink(tmpFile).catch(() => {}) + }) + }) + + describe("disabled config", () => { + test("does nothing when hashline_edit is disabled", async () => { + const disabledHook = createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: false } }) + const tmpDir = (await import("os")).tmpdir() + const tmpFile = `${tmpDir}/hashline-diff-disabled-${Date.now()}.ts` + await Bun.write(tmpFile, "content") + + const input = makeInput("edit", "call-disabled") + await disabledHook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile })) + await Bun.write(tmpFile, "modified") + + const afterOutput = makeAfterOutput() + await disabledHook["tool.execute.after"](input, afterOutput) + + // then - no filediff injected + expect(afterOutput.metadata.filediff).toBeUndefined() + + await (await import("fs/promises")).unlink(tmpFile).catch(() => {}) + }) + }) +}) diff --git a/src/hooks/hashline-edit-diff-enhancer/index.ts b/src/hooks/hashline-edit-diff-enhancer/index.ts new file mode 100644 index 00000000..883bccfe --- /dev/null +++ b/src/hooks/hashline-edit-diff-enhancer/index.ts @@ -0,0 +1 @@ +export { createHashlineEditDiffEnhancerHook } from "./hook" diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 950cc974..37f99df8 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -46,4 +46,5 @@ export { createPreemptiveCompactionHook } from "./preemptive-compaction"; export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler"; export { createWriteExistingFileGuardHook } from "./write-existing-file-guard"; export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer"; +export { createHashlineEditDiffEnhancerHook } from "./hashline-edit-diff-enhancer"; diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts index 6982376f..8f1e7f81 100644 --- a/src/plugin/hooks/create-tool-guard-hooks.ts +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -12,6 +12,7 @@ import { createTasksTodowriteDisablerHook, createWriteExistingFileGuardHook, createHashlineReadEnhancerHook, + createHashlineEditDiffEnhancerHook, } from "../../hooks" import { getOpenCodeVersion, @@ -31,6 +32,7 @@ export type ToolGuardHooks = { tasksTodowriteDisabler: ReturnType | null writeExistingFileGuard: ReturnType | null hashlineReadEnhancer: ReturnType | null + hashlineEditDiffEnhancer: ReturnType | null } export function createToolGuardHooks(args: { @@ -99,6 +101,10 @@ export function createToolGuardHooks(args: { ? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.experimental?.hashline_edit ?? true } })) : null + const hashlineEditDiffEnhancer = isHookEnabled("hashline-edit-diff-enhancer") + ? safeHook("hashline-edit-diff-enhancer", () => createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: pluginConfig.experimental?.hashline_edit ?? true } })) + : null + return { commentChecker, toolOutputTruncator, @@ -109,5 +115,6 @@ export function createToolGuardHooks(args: { tasksTodowriteDisabler, writeExistingFileGuard, hashlineReadEnhancer, + hashlineEditDiffEnhancer, } } diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts index 0ecfcb99..a6b6fae2 100644 --- a/src/plugin/tool-execute-after.ts +++ b/src/plugin/tool-execute-after.ts @@ -45,5 +45,6 @@ export function createToolExecuteAfterHandler(args: { await hooks.atlasHook?.["tool.execute.after"]?.(input, output) await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output) await hooks.hashlineReadEnhancer?.["tool.execute.after"]?.(input, output) + await hooks.hashlineEditDiffEnhancer?.["tool.execute.after"]?.(input, output) } } diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index 09ae1681..d9a06c70 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -29,6 +29,7 @@ export function createToolExecuteBeforeHandler(args: { await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output) await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output) await hooks.atlasHook?.["tool.execute.before"]?.(input, output) + await hooks.hashlineEditDiffEnhancer?.["tool.execute.before"]?.(input, output) if (input.tool === "task") { const argsObject = output.args const category = typeof argsObject.category === "string" ? argsObject.category : undefined