417 lines · 12.8 KB · TypeScript
RawDownload
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 "../Errors.ts";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 { normalizeCodexModelOptions } from "../../provider/Layers/CodexProvider.ts";import { ServerSettingsService } from "../../serverSettings.ts"; 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<string>;  };   const readStreamAsString = <E>(    operation: string,    stream: Stream.Stream<Uint8Array, E>,  ): Effect.Effect<string, TextGenerationError> =>    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<string, TextGenerationError, Scope.Scope> => {    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<void, never> =>    fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void));   const materializeImageAttachments = (    _operation:      | "generateCommitMessage"      | "generatePrContent"      | "generateBranchName"      | "generateThreadTitle",    attachments: BranchNameGenerationInput["attachments"],  ): Effect.Effect<MaterializedImageAttachments, TextGenerationError> =>    Effect.gen(function* () {      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 = <S extends Schema.Top>({    operation,    cwd,    prompt,    outputSchemaJson,    imagePaths = [],    cleanupPaths = [],    modelSelection,  }: {    operation:      | "generateCommitMessage"      | "generatePrContent"      | "generateBranchName"      | "generateThreadTitle";    cwd: string;    prompt: string;    outputSchemaJson: S;    imagePaths?: ReadonlyArray<string>;    cleanupPaths?: ReadonlyArray<string>;    modelSelection: CodexModelSelection;  }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>    Effect.gen(function* () {      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.gen(function* () {        const normalizedOptions = normalizeCodexModelOptions(          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);