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);
};
}