From c576eae67934048932857ffeafec7766052d07f2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 19:12:06 +0900 Subject: [PATCH] feat(omo-codex): batch 35 (4 files) --- .../components/comment-checker/src/cli.ts | 12 + .../comment-checker/src/codex-hook.ts | 205 ++++++++++ .../components/comment-checker/src/core.ts | 361 ++++++++++++++++++ .../components/comment-checker/src/runner.ts | 195 ++++++++++ 4 files changed, 773 insertions(+) create mode 100644 packages/omo-codex/plugin/components/comment-checker/src/cli.ts create mode 100644 packages/omo-codex/plugin/components/comment-checker/src/codex-hook.ts create mode 100644 packages/omo-codex/plugin/components/comment-checker/src/core.ts create mode 100644 packages/omo-codex/plugin/components/comment-checker/src/runner.ts diff --git a/packages/omo-codex/plugin/components/comment-checker/src/cli.ts b/packages/omo-codex/plugin/components/comment-checker/src/cli.ts new file mode 100644 index 000000000..bc16c1fbb --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/src/cli.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +import { runCodexHookCli } from "./codex-hook.js"; + +const [command, subcommand] = process.argv.slice(2); + +if (command === "hook" && subcommand === "post-tool-use") { + await runCodexHookCli(); +} else { + process.stderr.write("Usage: omo-comment-checker hook post-tool-use\n"); + process.exitCode = 2; +} diff --git a/packages/omo-codex/plugin/components/comment-checker/src/codex-hook.ts b/packages/omo-codex/plugin/components/comment-checker/src/codex-hook.ts new file mode 100644 index 000000000..ff1239ec6 --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/src/codex-hook.ts @@ -0,0 +1,205 @@ +import { readFileSync } from "node:fs"; +import { stdin as processStdin, stdout as processStdout } from "node:process"; + +import { + type CommentCheckRequest, + extractCommentCheckRequests, + isRecord, + type ToolResultContent, + type ToolResultLike, + toHookInput, +} from "./core.js"; +import { type CommentCheckerRunner, runCommentChecker } from "./runner.js"; + +export type CodexPostToolUseInput = { + session_id: string; + turn_id: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "PostToolUse"; + model: string; + permission_mode: string; + tool_name: string; + tool_input: Record; + tool_response: unknown; + tool_use_id: string; +}; + +export type CodexHookOptions = { + run?: CommentCheckerRunner; +}; + +const DEFAULT_MAX_HOOK_FEEDBACK_CHARS = 8000; +const CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS = 1200; +const CONTEXT_PRESSURE_MARKERS = [ + "context compacted", + "context_length_exceeded", + "skill descriptions were shortened", + "context_too_large", + "codex ran out of room in the model's context window", + "your input exceeds the context window", + "long threads and multiple compactions", +] as const; + +export function extractCodexCommentCheckRequests(input: CodexPostToolUseInput): CommentCheckRequest[] { + return extractCommentCheckRequests(toToolResultLike(input)); +} + +export async function runCommentCheckerPostToolUse( + input: CodexPostToolUseInput, + options: CodexHookOptions = {}, +): Promise { + const requests = extractCodexCommentCheckRequests(input); + if (requests.length === 0) return ""; + + const runner = options.run ?? runCommentChecker; + const warnings: Array<{ filePath: string; message: string }> = []; + + for (const request of requests) { + const context = { + sessionId: input.session_id, + cwd: input.cwd, + ...(input.transcript_path === null ? {} : { transcriptPath: input.transcript_path }), + }; + const result = await runner(toHookInput(request, context)); + if (result.status === "missing" || result.status === "pass") continue; + if (result.status === "error") continue; + const message = normalizeHookText(result.message); + if (message.length > 0) { + warnings.push({ filePath: request.filePath, message }); + } + } + + if (warnings.length === 0) return ""; + + return JSON.stringify({ + decision: "block", + reason: limitHookText(formatWarnings(warnings), hookFeedbackLimit(input.transcript_path)), + }); +} + +export async function runCodexHookCli(): Promise { + const input = await readStdin(); + if (input.trim().length === 0) return; + const parsed = parseCodexPostToolUseInput(input); + if (!parsed) return; + const output = await runCommentCheckerPostToolUse(parsed); + if (output.length > 0) { + processStdout.write(output); + processStdout.write("\n"); + } +} + +export function parseCodexPostToolUseInput(input: string): CodexPostToolUseInput | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch { + return undefined; + } + return isCodexPostToolUseInput(parsed) ? parsed : undefined; +} + +function toToolResultLike(input: CodexPostToolUseInput): ToolResultLike { + return { + toolName: input.tool_name, + input: normalizeToolInput(input.tool_name, input.tool_input), + content: normalizeToolResponse(input.tool_response), + isError: isErrorResponse(input.tool_response), + details: isRecord(input.tool_response) ? input.tool_response : undefined, + }; +} + +function normalizeToolInput(toolName: string, toolInput: Record): Record { + if (toolName === "apply_patch" && typeof toolInput["command"] === "string") { + return { + ...toolInput, + input: toolInput["command"], + patch: toolInput["command"], + }; + } + return toolInput; +} + +function normalizeToolResponse(toolResponse: unknown): ToolResultContent[] { + if (typeof toolResponse === "string") { + return [{ type: "text", text: toolResponse }]; + } + if (isRecord(toolResponse) && typeof toolResponse["text"] === "string") { + return [{ type: "text", text: toolResponse["text"] }]; + } + return []; +} + +function isErrorResponse(toolResponse: unknown): boolean { + return isRecord(toolResponse) && toolResponse["is_error"] === true; +} + +function formatWarnings(warnings: Array<{ filePath: string; message: string }>): string { + return warnings + .map((warning) => `comment-checker found issues in ${warning.filePath}:\n${warning.message}`) + .join("\n\n"); +} + +function normalizeHookText(value: string): string { + return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); +} + +function hookFeedbackLimit(transcriptPath: string | null): number { + return isContextPressureTranscript(transcriptPath) + ? CONTEXT_PRESSURE_MAX_HOOK_FEEDBACK_CHARS + : DEFAULT_MAX_HOOK_FEEDBACK_CHARS; +} + +function isContextPressureTranscript(transcriptPath: string | null): boolean { + if (transcriptPath === null) return false; + try { + return hasContextPressureMarker(readFileSync(transcriptPath, "utf8")); + } catch (error) { + if (error instanceof Error) return false; + throw error; + } +} + +function hasContextPressureMarker(text: string): boolean { + const normalizedText = text.toLowerCase(); + return CONTEXT_PRESSURE_MARKERS.some((marker) => normalizedText.includes(marker)); +} + +function limitHookText(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + const marker = `\n\n[Truncated hook output to ${maxChars} chars to avoid Codex context overflow.]`; + if (marker.length >= maxChars) return marker.slice(0, maxChars); + const head = text.slice(0, maxChars - marker.length).replace(/[ \t\r\n]+$/, ""); + return `${head}${marker}`; +} + +function isCodexPostToolUseInput(value: unknown): value is CodexPostToolUseInput { + return ( + isRecord(value) && + value["hook_event_name"] === "PostToolUse" && + typeof value["session_id"] === "string" && + typeof value["turn_id"] === "string" && + (typeof value["transcript_path"] === "string" || value["transcript_path"] === null) && + typeof value["cwd"] === "string" && + typeof value["model"] === "string" && + typeof value["permission_mode"] === "string" && + typeof value["tool_name"] === "string" && + isRecord(value["tool_input"]) && + typeof value["tool_use_id"] === "string" + ); +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ""; + processStdin.setEncoding("utf-8"); + processStdin.on("data", (chunk: string) => { + data += chunk; + }); + processStdin.once("error", reject); + processStdin.once("end", () => { + resolve(data); + }); + }); +} diff --git a/packages/omo-codex/plugin/components/comment-checker/src/core.ts b/packages/omo-codex/plugin/components/comment-checker/src/core.ts new file mode 100644 index 000000000..d8506d69d --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/src/core.ts @@ -0,0 +1,361 @@ +export type TextContent = { + type: "text"; + text: string; +}; + +export type ImageContent = { + type: "image"; + data: string; + mimeType: string; +}; + +export type CheckerToolName = "Write" | "Edit" | "MultiEdit"; + +export type CheckerEdit = { + old_string: string; + new_string: string; +}; + +export type CheckerToolInput = { + file_path: string; + content?: string; + old_string?: string; + new_string?: string; + edits?: CheckerEdit[]; +}; + +export type CommentCheckRequest = { + sourceToolName: string; + toolName: CheckerToolName; + filePath: string; + toolInput: CheckerToolInput; +}; + +export type CommentCheckerHookInput = { + session_id: string; + tool_name: CheckerToolName; + transcript_path: string; + cwd: string; + hook_event_name: "PostToolUse"; + tool_input: CheckerToolInput; +}; + +export type ToolResultContent = TextContent | ImageContent; + +export type ToolResultLike = { + toolName: string; + input: Record; + content?: ToolResultContent[]; + isError?: boolean; + details?: unknown; +}; + +type ApplyPatchAccumulator = { + operation: "add" | "delete" | "update"; + filePath: string; + movePath?: string; + oldLines: string[]; + newLines: string[]; +}; + +type ApplyPatchFileMetadata = { + filePath: string; + movePath?: string; + before: string; + after: string; + type?: string; +}; + +export function extractCommentCheckRequests(event: ToolResultLike): CommentCheckRequest[] { + if (event.isError) return []; + if (isToolFailureOutput(getContentText(event.content))) return []; + + const toolName = event.toolName.toLowerCase(); + if (toolName === "write") return extractWriteRequest(event); + if (toolName === "edit") return extractEditRequest(event); + if (toolName === "multiedit" || toolName === "multi_edit") return extractMultiEditRequest(event); + if (toolName === "apply_patch") return extractApplyPatchRequests(event); + return []; +} + +export function toHookInput( + request: CommentCheckRequest, + context: { + sessionId: string; + cwd: string; + transcriptPath?: string; + }, +): CommentCheckerHookInput { + return { + session_id: context.sessionId, + tool_name: request.toolName, + transcript_path: context.transcriptPath ?? "", + cwd: context.cwd, + hook_event_name: "PostToolUse", + tool_input: request.toolInput, + }; +} + +export function isToolFailureOutput(text: string): boolean { + const lower = text.trim().toLowerCase(); + return ( + lower.startsWith("error") || + lower.includes("error:") || + lower.includes("failed to") || + lower.includes("could not") + ); +} + +function extractWriteRequest(event: ToolResultLike): CommentCheckRequest[] { + const filePath = getString(event.input, ["filePath", "file_path", "path"]); + const content = getString(event.input, ["content"]); + if (!filePath || content === undefined) return []; + return [ + { + sourceToolName: event.toolName, + toolName: "Write", + filePath, + toolInput: { + file_path: filePath, + content, + }, + }, + ]; +} + +function extractEditRequest(event: ToolResultLike): CommentCheckRequest[] { + const filePath = getString(event.input, ["filePath", "file_path", "path"]); + const oldString = getString(event.input, ["oldString", "old_string"]); + const newString = getString(event.input, ["newString", "new_string"]); + if (!filePath || oldString === undefined || newString === undefined) return []; + const toolInput: CheckerToolInput = { file_path: filePath }; + toolInput.old_string = oldString; + toolInput.new_string = newString; + return [ + { + sourceToolName: event.toolName, + toolName: "Edit", + filePath, + toolInput, + }, + ]; +} + +function extractMultiEditRequest(event: ToolResultLike): CommentCheckRequest[] { + const filePath = getString(event.input, ["filePath", "file_path", "path"]); + const edits = getEdits(event.input["edits"]); + if (!filePath || edits.length === 0) return []; + return [ + { + sourceToolName: event.toolName, + toolName: "MultiEdit", + filePath, + toolInput: { + file_path: filePath, + edits, + }, + }, + ]; +} + +function extractApplyPatchRequests(event: ToolResultLike): CommentCheckRequest[] { + const metadataRequests = extractApplyPatchMetadataRequests(event.details, event.toolName); + if (metadataRequests.length > 0) return metadataRequests; + + const patch = getString(event.input, ["input", "patch", "command"]); + if (!patch) return []; + return parseApplyPatchRequests(patch, event.toolName); +} + +function extractApplyPatchMetadataRequests(details: unknown, sourceToolName: string): CommentCheckRequest[] { + const metadataFiles = getApplyPatchMetadataFiles(details); + if (metadataFiles.length === 0) return []; + + const requests: CommentCheckRequest[] = []; + for (const file of metadataFiles) { + if (file.type === "delete") continue; + const filePath = file.movePath ?? file.filePath; + if (file.before.length === 0) { + requests.push({ + sourceToolName, + toolName: "Write", + filePath, + toolInput: { + file_path: filePath, + content: file.after, + }, + }); + continue; + } + requests.push({ + sourceToolName, + toolName: "Edit", + filePath, + toolInput: { + file_path: filePath, + old_string: file.before, + new_string: file.after, + }, + }); + } + return requests; +} + +function getApplyPatchMetadataFiles(details: unknown): ApplyPatchFileMetadata[] { + if (!isRecord(details)) return []; + const direct = readApplyPatchMetadataFiles(details["files"]); + if (direct.length > 0) return direct; + const resultDetails = details["result"]; + const result = isRecord(resultDetails) ? readApplyPatchMetadataFiles(resultDetails["files"]) : []; + if (result.length > 0) return result; + const metadataDetails = details["metadata"]; + const metadata = isRecord(metadataDetails) ? readApplyPatchMetadataFiles(metadataDetails["files"]) : []; + return metadata; +} + +function readApplyPatchMetadataFiles(value: unknown): ApplyPatchFileMetadata[] { + if (!Array.isArray(value)) return []; + const files: ApplyPatchFileMetadata[] = []; + for (const item of value) { + if (!isRecord(item)) continue; + const filePath = getString(item, ["filePath", "file_path", "path"]); + const movePath = getString(item, ["movePath", "move_path"]); + const before = getString(item, ["before", "old", "oldString", "old_string"]); + const after = getString(item, ["after", "new", "newString", "new_string"]); + const type = getString(item, ["type", "operation"]); + if (!filePath || before === undefined || after === undefined) continue; + files.push({ + filePath, + before, + after, + ...(movePath === undefined ? {} : { movePath }), + ...(type === undefined ? {} : { type }), + }); + } + return files; +} + +export function parseApplyPatchRequests(patch: string, sourceToolName = "apply_patch"): CommentCheckRequest[] { + const requests: CommentCheckRequest[] = []; + let current: ApplyPatchAccumulator | undefined; + + const flush = (): void => { + if (!current) return; + if (current.operation === "add") { + const content = joinPatchLines(current.newLines); + if (content.length > 0) { + requests.push({ + sourceToolName, + toolName: "Write", + filePath: current.filePath, + toolInput: { + file_path: current.filePath, + content, + }, + }); + } + } + if (current.operation === "update") { + const newString = joinPatchLines(current.newLines); + if (newString.length > 0) { + const filePath = current.movePath ?? current.filePath; + requests.push({ + sourceToolName, + toolName: "Edit", + filePath, + toolInput: { + file_path: filePath, + old_string: joinPatchLines(current.oldLines), + new_string: newString, + }, + }); + } + } + current = undefined; + }; + + for (const line of patch.split(/\r?\n/)) { + if (line === "*** Begin Patch" || line === "*** End Patch") continue; + if (line.startsWith("*** Add File: ")) { + flush(); + current = makeAccumulator("add", line.slice("*** Add File: ".length).trim()); + continue; + } + if (line.startsWith("*** Update File: ")) { + flush(); + current = makeAccumulator("update", line.slice("*** Update File: ".length).trim()); + continue; + } + if (line.startsWith("*** Delete File: ")) { + flush(); + current = makeAccumulator("delete", line.slice("*** Delete File: ".length).trim()); + continue; + } + if (line.startsWith("*** Move to: ")) { + if (current?.operation === "update") current.movePath = line.slice("*** Move to: ".length).trim(); + continue; + } + if (!current) continue; + if (line.startsWith("@@")) continue; + if (current.operation === "add") { + if (line.startsWith("+")) current.newLines.push(line.slice(1)); + continue; + } + if (current.operation === "update") { + if (line.startsWith("+")) current.newLines.push(line.slice(1)); + if (line.startsWith("-")) current.oldLines.push(line.slice(1)); + } + } + + flush(); + return requests; +} + +function makeAccumulator(operation: ApplyPatchAccumulator["operation"], filePath: string): ApplyPatchAccumulator { + return { + operation, + filePath, + oldLines: [], + newLines: [], + }; +} + +function getEdits(value: unknown): CheckerEdit[] { + if (!Array.isArray(value)) return []; + const edits: CheckerEdit[] = []; + for (const item of value) { + if (!isRecord(item)) continue; + const oldString = getString(item, ["oldString", "old_string"]); + const newString = getString(item, ["newString", "new_string"]); + if (oldString === undefined || newString === undefined) continue; + edits.push({ + old_string: oldString, + new_string: newString, + }); + } + return edits; +} + +function getContentText(content: ToolResultContent[] | undefined): string { + if (!content) return ""; + return content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +function getString(input: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = input[key]; + if (typeof value === "string") return value; + } + return undefined; +} + +function joinPatchLines(lines: string[]): string { + return lines.length === 0 ? "" : `${lines.join("\n")}\n`; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/omo-codex/plugin/components/comment-checker/src/runner.ts b/packages/omo-codex/plugin/components/comment-checker/src/runner.ts new file mode 100644 index 000000000..7d6ce285b --- /dev/null +++ b/packages/omo-codex/plugin/components/comment-checker/src/runner.ts @@ -0,0 +1,195 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +import type { CommentCheckerHookInput } from "./core.js"; + +export type ProcessResult = { + exitCode: number | null; + stdout: string; + stderr: string; +}; + +export const MAX_PROCESS_OUTPUT_BYTES = 64 * 1024; + +export type ProcessExecutor = (command: string, args: string[], stdin: string) => Promise; + +export type RunCommentCheckerOptions = { + binaryPath?: string; + customPrompt?: string; + resolveBinary?: () => string | undefined; + executor?: ProcessExecutor; +}; + +export type CommentCheckerRunResult = { + status: "pass" | "warning" | "error" | "missing"; + message: string; + binaryPath?: string; + exitCode?: number | null; + stdout?: string; + stderr?: string; +}; + +export type CommentCheckerRunner = (input: CommentCheckerHookInput) => Promise; + +export async function runCommentChecker( + input: CommentCheckerHookInput, + options: RunCommentCheckerOptions = {}, +): Promise { + const binaryPath = + options.binaryPath ?? (options.resolveBinary ? options.resolveBinary() : resolveCommentCheckerBinary()); + if (!binaryPath) { + return { + status: "missing", + message: "comment-checker binary not found. Run npm install for the codex-comment-checker plugin.", + }; + } + + const args = ["check"]; + if (options.customPrompt) { + args.push("--prompt", options.customPrompt); + } + + const executor = options.executor ?? spawnProcess; + const result = await executor(binaryPath, args, JSON.stringify(input)); + const message = result.stderr || result.stdout; + if (result.exitCode === 0) { + return { + status: "pass", + message: "", + binaryPath, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; + } + if (result.exitCode === 2) { + return { + status: "warning", + message, + binaryPath, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; + } + return { + status: "error", + message, + binaryPath, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; +} + +export function resolveCommentCheckerBinary(): string | undefined { + const binaryName = process.platform === "win32" ? "comment-checker.exe" : "comment-checker"; + const fromPackageApi = resolvePackageApiBinary(); + if (fromPackageApi) return fromPackageApi; + const fromPackage = resolvePackageBinary(binaryName); + if (fromPackage) return fromPackage; + return undefined; +} + +function resolvePackageApiBinary(): string | undefined { + try { + const require = createRequire(import.meta.url); + const packageExports: unknown = require("@code-yeongyu/comment-checker"); + if (!isCommentCheckerPackage(packageExports)) return undefined; + const binaryPath = packageExports.getBinaryPath(); + return existsSync(binaryPath) ? binaryPath : undefined; + } catch { + return undefined; + } +} + +function resolvePackageBinary(binaryName: string): string | undefined { + try { + const require = createRequire(import.meta.url); + const packagePath = require.resolve("@code-yeongyu/comment-checker/package.json"); + const binaryPath = join(dirname(packagePath), "bin", binaryName); + return existsSync(binaryPath) ? binaryPath : undefined; + } catch { + return undefined; + } +} + +function isCommentCheckerPackage(value: unknown): value is { getBinaryPath: () => string } { + return isRecord(value) && typeof value["getBinaryPath"] === "function"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +interface OutputAccumulator { + text: string; + bytes: number; + truncated: boolean; +} + +function appendOutput(output: OutputAccumulator, chunk: string, maxOutputBytes: number): void { + if (output.truncated) return; + + const remainingBytes = maxOutputBytes - output.bytes; + const chunkBytes = Buffer.byteLength(chunk, "utf8"); + if (chunkBytes <= remainingBytes) { + output.text += chunk; + output.bytes += chunkBytes; + return; + } + + if (remainingBytes > 0) { + output.text += Buffer.from(chunk, "utf8").subarray(0, remainingBytes).toString("utf8"); + output.bytes += remainingBytes; + } + output.truncated = true; +} + +function formatOutput(output: OutputAccumulator, streamName: "stdout" | "stderr", maxOutputBytes: number): string { + if (!output.truncated) return output.text; + return `${output.text}\n[${streamName} truncated after ${maxOutputBytes} bytes]`; +} + +export function spawnProcess( + command: string, + args: string[], + stdin: string, + maxOutputBytes: number = MAX_PROCESS_OUTPUT_BYTES, +): Promise { + return new Promise((resolve) => { + const outputByteLimit = Number.isFinite(maxOutputBytes) && maxOutputBytes > 0 ? Math.floor(maxOutputBytes) : 0; + const proc = spawn(command, args, { + stdio: ["pipe", "pipe", "pipe"], + }); + const stdout: OutputAccumulator = { text: "", bytes: 0, truncated: false }; + const stderr: OutputAccumulator = { text: "", bytes: 0, truncated: false }; + + proc.stdout.setEncoding("utf-8"); + proc.stderr.setEncoding("utf-8"); + proc.stdout.on("data", (chunk: string) => { + appendOutput(stdout, chunk, outputByteLimit); + }); + proc.stderr.on("data", (chunk: string) => { + appendOutput(stderr, chunk, outputByteLimit); + }); + proc.once("error", (error) => { + appendOutput(stderr, error.message, outputByteLimit); + resolve({ + exitCode: null, + stdout: formatOutput(stdout, "stdout", outputByteLimit), + stderr: formatOutput(stderr, "stderr", outputByteLimit), + }); + }); + proc.once("close", (exitCode) => { + resolve({ + exitCode, + stdout: formatOutput(stdout, "stdout", outputByteLimit), + stderr: formatOutput(stderr, "stderr", outputByteLimit), + }); + }); + proc.stdin.end(stdin); + }); +}