import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { CommitMessageGenerationInput, CommitMessageGenerationResult, PrContentGenerationInput, PrContentGenerationResult, TextGenerationService, } from "./coreServices"; import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner"; type ProcessRunner = ( command: string, args: readonly string[], options?: ProcessRunOptions, ) => Promise; const CODEX_MODEL = "gpt-5.3-codex"; const CODEX_REASONING_EFFORT = "low"; const COMMIT_OUTPUT_SCHEMA_JSON = { type: "object", properties: { subject: { type: "string" }, body: { type: "string" }, }, required: ["subject", "body"], additionalProperties: false, } as const; const PR_OUTPUT_SCHEMA_JSON = { type: "object", properties: { title: { type: "string" }, body: { type: "string" }, }, required: ["title", "body"], additionalProperties: false, } as const; function parseCommitOutput(raw: unknown): { subject: string; body: string } { if (!raw || typeof raw !== "object") { throw new Error("Codex returned a non-object commit message payload."); } const record = raw as Record; const subject = typeof record.subject === "string" ? record.subject.trim() : ""; const body = typeof record.body === "string" ? record.body : ""; if (subject.length === 0) { throw new Error("Codex returned an empty commit subject."); } return { subject, body }; } function parsePrOutput(raw: unknown): { title: string; body: string } { if (!raw || typeof raw !== "object") { throw new Error("Codex returned a non-object PR payload."); } const record = raw as Record; const title = typeof record.title === "string" ? record.title.trim() : ""; const body = typeof record.body === "string" ? record.body.trim() : ""; if (title.length === 0 || body.length === 0) { throw new Error("Codex returned an invalid PR title/body payload."); } return { title, body }; } function limitSection(value: string, maxChars: number): string { if (value.length <= maxChars) return value; const truncated = value.slice(0, maxChars); return `${truncated}\n\n[truncated]`; } async function writeTempFile(prefix: string, content: string): Promise { const filePath = path.join(os.tmpdir(), `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`); await fs.writeFile(filePath, content, "utf8"); return filePath; } async function safeUnlink(filePath: string): Promise { try { await fs.unlink(filePath); } catch { // Best-effort cleanup. } } function sanitizeCommitSubject(raw: string): string { const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); if (withoutTrailingPeriod.length === 0) { return "Update project files"; } if (withoutTrailingPeriod.length <= 72) { return withoutTrailingPeriod; } return withoutTrailingPeriod.slice(0, 72).trimEnd(); } function sanitizePrTitle(raw: string): string { const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; if (singleLine.length > 0) { return singleLine; } return "Update project changes"; } async function runCodexJson({ cwd, prompt, outputSchemaJson, parse, run, }: { cwd: string; prompt: string; outputSchemaJson: object; parse: (raw: unknown) => T; run: ProcessRunner; }): Promise { const schemaPath = await writeTempFile("codex-schema", JSON.stringify(outputSchemaJson)); let outputPath: string | null = null; try { outputPath = await writeTempFile("codex-output", ""); await run( "codex", [ "exec", "--ephemeral", "-s", "read-only", "--model", CODEX_MODEL, "--config", `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`, "--output-schema", schemaPath, "--output-last-message", outputPath, "-", ], { cwd, timeoutMs: 180_000, stdin: prompt, }, ); const rawOutput = (await fs.readFile(outputPath, "utf8")).trim(); if (rawOutput.length === 0) { throw new Error("Codex returned an empty response."); } let parsedJson: unknown; try { parsedJson = JSON.parse(rawOutput); } catch { throw new Error("Codex returned invalid JSON output."); } return parse(parsedJson); } finally { await Promise.all([safeUnlink(schemaPath), ...(outputPath ? [safeUnlink(outputPath)] : [])]); } } interface CodexTextGeneratorDeps { runProcess?: ProcessRunner; } export class CodexTextGenerator implements TextGenerationService { private readonly run: ProcessRunner; constructor(deps: CodexTextGeneratorDeps = {}) { this.run = deps.runProcess ?? runProcess; } async generateCommitMessage( input: CommitMessageGenerationInput, ): Promise { const prompt = [ "You write concise git commit messages.", "Return a JSON object with keys: subject, body.", "Rules:", "- subject must be imperative, <= 72 chars, and no trailing period", "- body can be empty string or short bullet points", "- capture the primary user-visible or developer-visible change", "", `Branch: ${input.branch ?? "(detached)"}`, "", "Staged files:", limitSection(input.stagedSummary, 6_000), "", "Staged patch:", limitSection(input.stagedPatch, 40_000), ].join("\n"); const generated = await runCodexJson({ cwd: input.cwd, prompt, outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON, parse: (raw) => parseCommitOutput(raw), run: this.run, }); return { subject: sanitizeCommitSubject(generated.subject), body: generated.body.trim(), }; } async generatePrContent(input: PrContentGenerationInput): Promise { const prompt = [ "You write GitHub pull request content.", "Return a JSON object with keys: title, body.", "Rules:", "- title should be concise and specific", "- body must be markdown and include headings '## Summary' and '## Testing'", "- under Summary, provide short bullet points", "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", "", `Base branch: ${input.baseBranch}`, `Head branch: ${input.headBranch}`, "", "Commits:", limitSection(input.commitSummary, 12_000), "", "Diff stat:", limitSection(input.diffSummary, 12_000), "", "Diff patch:", limitSection(input.diffPatch, 40_000), ].join("\n"); const generated = await runCodexJson({ cwd: input.cwd, prompt, outputSchemaJson: PR_OUTPUT_SCHEMA_JSON, parse: (raw) => parsePrOutput(raw), run: this.run, }); return { title: sanitizePrTitle(generated.title), body: generated.body.trim(), }; } }