import type { PluginHookContributions } from "../../types/hook"; import { computeLineHash } from "../../tools/hashline-edit/hash-computation"; const WRITE_SUCCESS_MARKER = "File written successfully."; const COLON_READ_LINE_PATTERN = /^\s*(\d+): ?(.*)$/; const PIPE_READ_LINE_PATTERN = /^\s*(\d+)\| ?(.*)$/; const CONTENT_OPEN_TAG = ""; const CONTENT_CLOSE_TAG = ""; const FILE_OPEN_TAG = ""; const FILE_CLOSE_TAG = ""; const OPENCODE_LINE_TRUNCATION_SUFFIX = "... (line truncated to 2000 chars)"; type PostToolUseHook = NonNullable; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function isReadTool(toolName: string): boolean { return toolName.toLowerCase() === "read"; } function isWriteTool(toolName: string): boolean { return toolName.toLowerCase() === "write"; } function isTextFile(output: string): boolean { const firstLine = output.split("\n")[0] ?? ""; return COLON_READ_LINE_PATTERN.test(firstLine) || PIPE_READ_LINE_PATTERN.test(firstLine); } function parseReadLine(line: string): { lineNumber: number; content: string } | null { const colonMatch = COLON_READ_LINE_PATTERN.exec(line); if (colonMatch) { return { lineNumber: Number.parseInt(colonMatch[1], 10), content: colonMatch[2] ?? "", }; } const pipeMatch = PIPE_READ_LINE_PATTERN.exec(line); if (pipeMatch) { return { lineNumber: Number.parseInt(pipeMatch[1], 10), content: pipeMatch[2] ?? "", }; } return null; } function transformLine(line: string): string { const parsed = parseReadLine(line); if (!parsed) return line; if (parsed.content.endsWith(OPENCODE_LINE_TRUNCATION_SUFFIX)) return line; const hash = computeLineHash(parsed.lineNumber, parsed.content); return `${parsed.lineNumber}#${hash}|${parsed.content}`; } function transformOutput(output: string): string { if (!output) return output; const lines = output.split("\n"); const contentStart = lines.findIndex( (line) => line === CONTENT_OPEN_TAG || line.startsWith(CONTENT_OPEN_TAG), ); const contentEnd = lines.indexOf(CONTENT_CLOSE_TAG); const fileStart = lines.findIndex( (line) => line === FILE_OPEN_TAG || line.startsWith(FILE_OPEN_TAG), ); const fileEnd = lines.indexOf(FILE_CLOSE_TAG); const blockStart = contentStart !== -1 ? contentStart : fileStart; const blockEnd = contentStart !== -1 ? contentEnd : fileEnd; const openTag = contentStart !== -1 ? CONTENT_OPEN_TAG : FILE_OPEN_TAG; if (blockStart !== -1 && blockEnd !== -1 && blockEnd > blockStart) { const openLine = lines[blockStart] ?? ""; const inlineFirst = openLine.startsWith(openTag) && openLine !== openTag ? openLine.slice(openTag.length) : null; const fileLines = inlineFirst !== null ? [inlineFirst, ...lines.slice(blockStart + 1, blockEnd)] : lines.slice(blockStart + 1, blockEnd); if (!isTextFile(fileLines[0] ?? "")) return output; const result: string[] = []; for (const line of fileLines) { if (!parseReadLine(line)) { result.push(...fileLines.slice(result.length)); break; } result.push(transformLine(line)); } const prefixLines = inlineFirst !== null ? [...lines.slice(0, blockStart), openTag] : lines.slice(0, blockStart + 1); return [...prefixLines, ...result, ...lines.slice(blockEnd)].join("\n"); } if (!isTextFile(lines[0] ?? "")) return output; const result: string[] = []; for (const line of lines) { if (!parseReadLine(line)) { result.push(...lines.slice(result.length)); break; } result.push(transformLine(line)); } return result.join("\n"); } function extractFilePath(metadata: unknown): string | undefined { if (!isRecord(metadata)) return undefined; const candidates = [metadata.filepath, metadata.filePath, metadata.path, metadata.file]; for (const candidate of candidates) { if (typeof candidate === "string" && candidate.length > 0) return candidate; } return undefined; } async function appendWriteHashlineOutput(output: Record): Promise { const outputStr = output.output; if (typeof outputStr !== "string") return; if (outputStr.startsWith(WRITE_SUCCESS_MARKER)) return; const outputLower = outputStr.toLowerCase(); if (outputLower.startsWith("error") || outputLower.includes("failed")) return; const filePath = extractFilePath(output.metadata); if (!filePath) return; const file = Bun.file(filePath); if (!(await file.exists())) return; const content = await file.text(); const lines = content.split("\n"); const lineCount = content === "" ? 0 : lines[lines.length - 1] === "" ? lines.length - 1 : lines.length; output.output = `${WRITE_SUCCESS_MARKER} ${lineCount} lines written.`; } export function createHashlineReadEnhancerHandler(): PostToolUseHook { return async (input: unknown, output: unknown) => { if (!isRecord(input) || !isRecord(output)) return; const tool = input.tool; if (typeof tool !== "string") return; if (isWriteTool(tool) && typeof output.output === "string") { await appendWriteHashlineOutput(output); return; } if (!isReadTool(tool)) return; if (typeof output.output !== "string") return; output.output = transformOutput(output.output); }; }