import { randomUUID } from "node:crypto"; import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { CodexModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { TextGenerationError } from "@t3tools/contracts"; import { type BranchNameGenerationInput, type ThreadTitleGenerationResult, type TextGenerationShape, TextGeneration, } from "../Services/TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, } from "../Prompts.ts"; import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; import { getCodexModelCapabilities } from "../../provider/Layers/CodexProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { normalizeCodexModelOptionsWithCapabilities } from "@t3tools/shared/model"; const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); const serverSettingsService = yield* Effect.service(ServerSettingsService); type MaterializedImageAttachments = { readonly imagePaths: ReadonlyArray; }; const readStreamAsString = ( operation: string, stream: Stream.Stream, ): Effect.Effect => stream.pipe( Stream.decodeText(), Stream.runFold( () => "", (acc, chunk) => acc + chunk, ), Effect.mapError((cause) => normalizeCliError("codex", operation, cause, "Failed to collect process output"), ), ); const writeTempFile = ( operation: string, prefix: string, content: string, ): Effect.Effect => { return fileSystem .makeTempFileScoped({ prefix: `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`, }) .pipe( Effect.tap((filePath) => fileSystem.writeFileString(filePath, content)), Effect.mapError( (cause) => new TextGenerationError({ operation, detail: `Failed to write temp file`, cause, }), ), ); }; const safeUnlink = (filePath: string): Effect.Effect => fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); const materializeImageAttachments = Effect.fn("materializeImageAttachments")(function* ( _operation: | "generateCommitMessage" | "generatePrContent" | "generateBranchName" | "generateThreadTitle", attachments: BranchNameGenerationInput["attachments"], ): Effect.fn.Return { if (!attachments || attachments.length === 0) { return { imagePaths: [] }; } const imagePaths: string[] = []; for (const attachment of attachments) { if (attachment.type !== "image") { continue; } const resolvedPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment, }); if (!resolvedPath || !path.isAbsolute(resolvedPath)) { continue; } const fileInfo = yield* fileSystem .stat(resolvedPath) .pipe(Effect.catch(() => Effect.succeed(null))); if (!fileInfo || fileInfo.type !== "File") { continue; } imagePaths.push(resolvedPath); } return { imagePaths }; }); const runCodexJson = Effect.fn("runCodexJson")(function* ({ operation, cwd, prompt, outputSchemaJson, imagePaths = [], cleanupPaths = [], modelSelection, }: { operation: | "generateCommitMessage" | "generatePrContent" | "generateBranchName" | "generateThreadTitle"; cwd: string; prompt: string; outputSchemaJson: S; imagePaths?: ReadonlyArray; cleanupPaths?: ReadonlyArray; modelSelection: CodexModelSelection; }): Effect.fn.Return { const schemaPath = yield* writeTempFile( operation, "codex-schema", JSON.stringify(toJsonSchemaObject(outputSchemaJson)), ); const outputPath = yield* writeTempFile(operation, "codex-output", ""); const codexSettings = yield* Effect.map( serverSettingsService.getSettings, (settings) => settings.providers.codex, ).pipe(Effect.catch(() => Effect.undefined)); const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { const normalizedOptions = normalizeCodexModelOptionsWithCapabilities( getCodexModelCapabilities(modelSelection.model), modelSelection.options, ); const reasoningEffort = modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; const command = ChildProcess.make( codexSettings?.binaryPath || "codex", [ "exec", "--ephemeral", "-s", "read-only", "--model", modelSelection.model, "--config", `model_reasoning_effort="${reasoningEffort}"`, ...(normalizedOptions?.fastMode ? ["--config", `service_tier="fast"`] : []), "--output-schema", schemaPath, "--output-last-message", outputPath, ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), "-", ], { env: { ...process.env, ...(codexSettings?.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), }, cwd, shell: process.platform === "win32", stdin: { stream: Stream.encodeText(Stream.make(prompt)), }, }, ); const child = yield* commandSpawner .spawn(command) .pipe( Effect.mapError((cause) => normalizeCliError("codex", operation, cause, "Failed to spawn Codex CLI process"), ), ); const [stdout, stderr, exitCode] = yield* Effect.all( [ readStreamAsString(operation, child.stdout), readStreamAsString(operation, child.stderr), child.exitCode.pipe( Effect.mapError((cause) => normalizeCliError("codex", operation, cause, "Failed to read Codex CLI exit code"), ), ), ], { concurrency: "unbounded" }, ); if (exitCode !== 0) { const stderrDetail = stderr.trim(); const stdoutDetail = stdout.trim(); const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; return yield* new TextGenerationError({ operation, detail: detail.length > 0 ? `Codex CLI command failed: ${detail}` : `Codex CLI command failed with code ${exitCode}.`, }); } }); const cleanup = Effect.all( [schemaPath, outputPath, ...cleanupPaths].map((filePath) => safeUnlink(filePath)), { concurrency: "unbounded", }, ).pipe(Effect.asVoid); return yield* Effect.gen(function* () { yield* runCodexCommand().pipe( Effect.scoped, Effect.timeoutOption(CODEX_TIMEOUT_MS), Effect.flatMap( Option.match({ onNone: () => Effect.fail( new TextGenerationError({ operation, detail: "Codex CLI request timed out." }), ), onSome: () => Effect.void, }), ), ); return yield* fileSystem.readFileString(outputPath).pipe( Effect.mapError( (cause) => new TextGenerationError({ operation, detail: "Failed to read Codex output file.", cause, }), ), Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))), Effect.catchTag("SchemaError", (cause) => Effect.fail( new TextGenerationError({ operation, detail: "Codex returned invalid structured output.", cause, }), ), ), ); }).pipe(Effect.ensuring(cleanup)); }); const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( "CodexTextGeneration.generateCommitMessage", )(function* (input) { const { prompt, outputSchema } = buildCommitMessagePrompt({ branch: input.branch, stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, }); if (input.modelSelection.provider !== "codex") { return yield* new TextGenerationError({ operation: "generateCommitMessage", detail: "Invalid model selection.", }); } const generated = yield* runCodexJson({ operation: "generateCommitMessage", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection, }); return { subject: sanitizeCommitSubject(generated.subject), body: generated.body.trim(), ...("branch" in generated && typeof generated.branch === "string" ? { branch: sanitizeFeatureBranchName(generated.branch) } : {}), }; }); const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( "CodexTextGeneration.generatePrContent", )(function* (input) { const { prompt, outputSchema } = buildPrContentPrompt({ baseBranch: input.baseBranch, headBranch: input.headBranch, commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, }); if (input.modelSelection.provider !== "codex") { return yield* new TextGenerationError({ operation: "generatePrContent", detail: "Invalid model selection.", }); } const generated = yield* runCodexJson({ operation: "generatePrContent", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection, }); return { title: sanitizePrTitle(generated.title), body: generated.body.trim(), }; }); const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( "CodexTextGeneration.generateBranchName", )(function* (input) { const { imagePaths } = yield* materializeImageAttachments( "generateBranchName", input.attachments, ); const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, }); if (input.modelSelection.provider !== "codex") { return yield* new TextGenerationError({ operation: "generateBranchName", detail: "Invalid model selection.", }); } const generated = yield* runCodexJson({ operation: "generateBranchName", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, imagePaths, modelSelection: input.modelSelection, }); return { branch: sanitizeBranchFragment(generated.branch), }; }); const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( "CodexTextGeneration.generateThreadTitle", )(function* (input) { const { imagePaths } = yield* materializeImageAttachments( "generateThreadTitle", input.attachments, ); const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, attachments: input.attachments, }); if (input.modelSelection.provider !== "codex") { return yield* new TextGenerationError({ operation: "generateThreadTitle", detail: "Invalid model selection.", }); } const generated = yield* runCodexJson({ operation: "generateThreadTitle", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, imagePaths, modelSelection: input.modelSelection, }); return { title: sanitizeThreadTitle(generated.title), } satisfies ThreadTitleGenerationResult; }); return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, } satisfies TextGenerationShape; }); export const CodexTextGenerationLive = Layer.effect(TextGeneration, makeCodexTextGeneration);