276 lines · 8.1 KB · TypeScript
RawDownload
import { Effect, Option, Ref, Schema } from "effect";import { ChildProcessSpawner } from "effect/unstable/process"; import { type CursorSettings, type ModelSelection } from "@t3tools/contracts";import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { TextGenerationError } from "@t3tools/contracts";import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts";import {  buildBranchNamePrompt,  buildCommitMessagePrompt,  buildPrContentPrompt,  buildThreadTitlePrompt,} from "./TextGenerationPrompts.ts";import {  extractJsonObject,  sanitizeCommitSubject,  sanitizePrTitle,  sanitizeThreadTitle,} from "./TextGenerationUtils.ts";import {  applyCursorAcpModelSelection,  makeCursorAcpRuntime,} from "../provider/acp/CursorAcpSupport.ts"; const CURSOR_TIMEOUT_MS = 180_000; function mapCursorAcpError(  operation:    | "generateCommitMessage"    | "generatePrContent"    | "generateBranchName"    | "generateThreadTitle",  detail: string,  cause: unknown,): TextGenerationError {  return new TextGenerationError({    operation,    detail,    ...(cause !== undefined ? { cause } : {}),  });} function isTextGenerationError(error: unknown): error is TextGenerationError {  return (    typeof error === "object" &&    error !== null &&    "_tag" in error &&    error._tag === "TextGenerationError"  );} /** * Build a Cursor text-generation closure bound to a specific `CursorSettings` * payload. See `makeCodexAdapter` for the overall per-instance rationale. */export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* (  cursorSettings: CursorSettings,  environment: NodeJS.ProcessEnv = process.env,) {  const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;   const runCursorJson = <S extends Schema.Top>({    operation,    cwd,    prompt,    outputSchemaJson,    modelSelection,  }: {    operation:      | "generateCommitMessage"      | "generatePrContent"      | "generateBranchName"      | "generateThreadTitle";    cwd: string;    prompt: string;    outputSchemaJson: S;    modelSelection: ModelSelection;  }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>    Effect.gen(function* () {      const outputRef = yield* Ref.make("");      const runtime = yield* makeCursorAcpRuntime({        cursorSettings,        environment,        childProcessSpawner: commandSpawner,        cwd,        clientInfo: { name: "t3-code-git-text", version: "0.0.0" },      });       yield* runtime.handleSessionUpdate((notification) => {        const update = notification.update;        if (update.sessionUpdate !== "agent_message_chunk") {          return Effect.void;        }        const content = update.content;        if (content.type !== "text") {          return Effect.void;        }        return Ref.update(outputRef, (current) => current + content.text);      });       const promptResult = yield* Effect.gen(function* () {        yield* runtime.start();        yield* Effect.ignore(runtime.setMode("ask"));        yield* applyCursorAcpModelSelection({          runtime,          model: modelSelection.model,          selections: modelSelection.options,          mapError: ({ cause, configId, step }) =>            mapCursorAcpError(              operation,              step === "set-config-option"                ? `Failed to set Cursor ACP config option "${configId}" for text generation.`                : "Failed to set Cursor ACP base model for text generation.",              cause,            ),        });         return yield* runtime.prompt({          prompt: [{ type: "text", text: prompt }],        });      }).pipe(        Effect.timeoutOption(CURSOR_TIMEOUT_MS),        Effect.flatMap(          Option.match({            onNone: () =>              Effect.fail(                new TextGenerationError({                  operation,                  detail: "Cursor Agent request timed out.",                }),              ),            onSome: (value) => Effect.succeed(value),          }),        ),        Effect.mapError((cause) =>          isTextGenerationError(cause)            ? cause            : mapCursorAcpError(operation, "Cursor ACP request failed.", cause),        ),      );       const rawResult = (yield* Ref.get(outputRef)).trim();      if (!rawResult) {        return yield* new TextGenerationError({          operation,          detail:            promptResult.stopReason === "cancelled"              ? "Cursor ACP request was cancelled."              : "Cursor Agent returned empty output.",        });      }       return yield* Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))(        extractJsonObject(rawResult),      ).pipe(        Effect.catchTag("SchemaError", (cause) =>          Effect.fail(            new TextGenerationError({              operation,              detail: "Cursor Agent returned invalid structured output.",              cause,            }),          ),        ),      );    }).pipe(      Effect.mapError((cause) =>        isTextGenerationError(cause)          ? cause          : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause),      ),      Effect.scoped,    );   const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(    "CursorTextGeneration.generateCommitMessage",  )(function* (input) {    const { prompt, outputSchema } = buildCommitMessagePrompt({      branch: input.branch,      stagedSummary: input.stagedSummary,      stagedPatch: input.stagedPatch,      includeBranch: input.includeBranch === true,    });     const generated = yield* runCursorJson({      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(    "CursorTextGeneration.generatePrContent",  )(function* (input) {    const { prompt, outputSchema } = buildPrContentPrompt({      baseBranch: input.baseBranch,      headBranch: input.headBranch,      commitSummary: input.commitSummary,      diffSummary: input.diffSummary,      diffPatch: input.diffPatch,    });     const generated = yield* runCursorJson({      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(    "CursorTextGeneration.generateBranchName",  )(function* (input) {    const { prompt, outputSchema } = buildBranchNamePrompt({      message: input.message,      attachments: input.attachments,    });     const generated = yield* runCursorJson({      operation: "generateBranchName",      cwd: input.cwd,      prompt,      outputSchemaJson: outputSchema,      modelSelection: input.modelSelection,    });     return {      branch: sanitizeBranchFragment(generated.branch),    };  });   const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(    "CursorTextGeneration.generateThreadTitle",  )(function* (input) {    const { prompt, outputSchema } = buildThreadTitlePrompt({      message: input.message,      attachments: input.attachments,    });     const generated = yield* runCursorJson({      operation: "generateThreadTitle",      cwd: input.cwd,      prompt,      outputSchemaJson: outputSchema,      modelSelection: input.modelSelection,    });     return {      title: sanitizeThreadTitle(generated.title),    } satisfies ThreadTitleGenerationResult;  });   return {    generateCommitMessage,    generatePrContent,    generateBranchName,    generateThreadTitle,  } satisfies TextGenerationShape;});