306 lines · 9.8 KB · TypeScript
RawDownload
import * as Crypto from "effect/Crypto";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Option from "effect/Option";import * as Ref from "effect/Ref";import * as Schema from "effect/Schema";import { ChildProcessSpawner } from "effect/unstable/process";import type * as EffectAcpErrors from "effect-acp/errors";import type * as EffectAcpSchema from "effect-acp/schema"; import {  type ChatAttachment,  type FxSettings,  type ModelSelection,  TextGenerationError,} from "@t3tools/contracts";import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { resolveAttachmentPath } from "../attachmentStore.ts";import { ServerConfig } from "../config.ts";import * as TextGeneration from "./TextGeneration.ts";import {  buildBranchNamePrompt,  buildCommitMessagePrompt,  buildPrContentPrompt,  buildThreadTitlePrompt,} from "./TextGenerationPrompts.ts";import {  sanitizeCommitSubject,  sanitizePrTitle,  sanitizeThreadTitle,} from "./TextGenerationUtils.ts";import {  applyFxAcpModelSelection,  currentFxModelIdFromSessionSetup,  makeFxAcpRuntime,  resolveFxAcpBaseModelId,} from "../provider/acp/FxAcpSupport.ts"; const FX_TIMEOUT_MS = 180_000; const isTextGenerationError = Schema.is(TextGenerationError); export const makeFxTextGeneration = Effect.fn("makeFxTextGeneration")(function* (  fxSettings: FxSettings,  environment: NodeJS.ProcessEnv = process.env,) {  const crypto = yield* Crypto.Crypto;  const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;  const fileSystem = yield* FileSystem.FileSystem;  const serverConfig = yield* ServerConfig;   const runFxJson = <S extends Schema.Top>({    operation,    cwd,    prompt,    outputSchemaJson,    modelSelection,    attachments,  }: {    operation:      | "generateCommitMessage"      | "generatePrContent"      | "generateBranchName"      | "generateThreadTitle";    cwd: string;    prompt: string;    outputSchemaJson: S;    modelSelection: ModelSelection;    attachments?: ReadonlyArray<ChatAttachment> | undefined;  }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>    Effect.gen(function* () {      const resolvedModel = resolveFxAcpBaseModelId(modelSelection.model);      const outputRef = yield* Ref.make("");      const runtime = yield* makeFxAcpRuntime({        fxSettings,        environment,        cwd,        clientInfo: { name: "t3-code-git-text", version: "0.0.0" },      }).pipe(        Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, commandSpawner),        Effect.provideService(Crypto.Crypto, crypto),      );       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 imagePromptParts = yield* Effect.forEach(attachments ?? [], (attachment) =>        Effect.gen(function* () {          const attachmentPath = resolveAttachmentPath({            attachmentsDir: serverConfig.attachmentsDir,            attachment,          });          if (!attachmentPath) {            return yield* new TextGenerationError({              operation,              detail: `Invalid fx text-generation attachment id '${attachment.id}'.`,            });          }          const bytes = yield* fileSystem.readFile(attachmentPath).pipe(            Effect.mapError(              (cause) =>                new TextGenerationError({                  operation,                  detail: "Failed to read an fx text-generation attachment.",                  cause,                }),            ),          );          return {            type: "image",            data: Buffer.from(bytes).toString("base64"),            mimeType: attachment.mimeType,          } satisfies EffectAcpSchema.ContentBlock;        }),      );       const promptResult = yield* Effect.gen(function* () {        const started = yield* runtime.start();        yield* applyFxAcpModelSelection({          runtime,          currentModelId: currentFxModelIdFromSessionSetup(started.sessionSetupResult),          requestedModelId: resolvedModel,          mapError: (cause) =>            new TextGenerationError({              operation,              detail: "Failed to set Fx ACP base model for text generation.",              cause,            }),        });         return yield* runtime.prompt({          prompt: [{ type: "text", text: prompt }, ...imagePromptParts],        });      }).pipe(        Effect.timeoutOption(FX_TIMEOUT_MS),        Effect.flatMap(          Option.match({            onNone: () =>              Effect.fail(                new TextGenerationError({ operation, detail: "Fx ACP request timed out." }),              ),            onSome: (value) => Effect.succeed(value),          }),        ),        Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) =>          isTextGenerationError(cause)            ? cause            : new TextGenerationError({                operation,                detail: "Fx ACP request failed.",                cause,              }),        ),      );       const trimmed = (yield* Ref.get(outputRef)).trim();      if (!trimmed) {        return yield* new TextGenerationError({          operation,          detail:            promptResult.stopReason === "cancelled"              ? "Fx ACP request was cancelled."              : "Fx Agent returned empty output.",        });      }       const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson));      return yield* decodeOutput(extractJsonObject(trimmed)).pipe(        Effect.catchTags({          SchemaError: (cause) =>            Effect.fail(              new TextGenerationError({                operation,                detail: "Fx Agent returned invalid structured output.",                cause,              }),            ),        }),      );    }).pipe(      Effect.mapError((cause) =>        isTextGenerationError(cause)          ? cause          : new TextGenerationError({              operation,              detail: "Fx ACP text generation failed.",              cause,            }),      ),      Effect.scoped,    );   const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] =    Effect.fn("FxTextGeneration.generateCommitMessage")(function* (input) {      const { prompt, outputSchema } = buildCommitMessagePrompt({        branch: input.branch,        stagedSummary: input.stagedSummary,        stagedPatch: input.stagedPatch,        includeBranch: input.includeBranch === true,        policy: input.policy,      });       const generated = yield* runFxJson({        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: TextGeneration.TextGeneration["Service"]["generatePrContent"] =    Effect.fn("FxTextGeneration.generatePrContent")(function* (input) {      const { prompt, outputSchema } = buildPrContentPrompt({        baseBranch: input.baseBranch,        headBranch: input.headBranch,        commitSummary: input.commitSummary,        diffSummary: input.diffSummary,        diffPatch: input.diffPatch,        policy: input.policy,        changeRequestTemplate: input.changeRequestTemplate,      });       const generated = yield* runFxJson({        operation: "generatePrContent",        cwd: input.cwd,        prompt,        outputSchemaJson: outputSchema,        modelSelection: input.modelSelection,      });       return {        title: sanitizePrTitle(generated.title),        body: generated.body.trim(),      };    });   const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] =    Effect.fn("FxTextGeneration.generateBranchName")(function* (input) {      const { prompt, outputSchema } = buildBranchNamePrompt({        message: input.message,        attachments: input.attachments,      });       const generated = yield* runFxJson({        operation: "generateBranchName",        cwd: input.cwd,        prompt,        outputSchemaJson: outputSchema,        modelSelection: input.modelSelection,        attachments: input.attachments,      });       return {        branch: sanitizeBranchFragment(generated.branch),      };    });   const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] =    Effect.fn("FxTextGeneration.generateThreadTitle")(function* (input) {      const { prompt, outputSchema } = buildThreadTitlePrompt({        message: input.message,        previousTitle: input.previousTitle,        attachments: input.attachments,      });       const generated = yield* runFxJson({        operation: "generateThreadTitle",        cwd: input.cwd,        prompt,        outputSchemaJson: outputSchema,        modelSelection: input.modelSelection,        attachments: input.attachments,      });       return {        title: sanitizeThreadTitle(generated.title),      } satisfies TextGeneration.ThreadTitleGenerationResult;    });   return {    generateCommitMessage,    generatePrContent,    generateBranchName,    generateThreadTitle,  } satisfies TextGeneration.TextGeneration["Service"];});