import { randomUUID } from "node:crypto"; import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; 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 BranchNameGenerationResult, type CommitMessageGenerationResult, type PrContentGenerationResult, type TextGenerationShape, TextGeneration,} from "../Services/TextGeneration.ts"; const CODEX_MODEL = "gpt-5.3-codex";const CODEX_REASONING_EFFORT = "low";const CODEX_TIMEOUT_MS = 180_000; function toCodexOutputJsonSchema(schema: Schema.Top): unknown { const document = Schema.toJsonSchemaDocument(schema); if (document.definitions && Object.keys(document.definitions).length > 0) { return { ...document.schema, $defs: document.definitions, }; } return document.schema;} function normalizeCodexError( operation: string, error: unknown, fallback: string,): TextGenerationError { if (Schema.is(TextGenerationError)(error)) { return error; } if (error instanceof Error) { const lower = error.message.toLowerCase(); if ( error.message.includes("Command not found: codex") || lower.includes("spawn codex") || lower.includes("enoent") ) { return new TextGenerationError({ operation, detail: "Codex CLI (`codex`) is required but not available on PATH.", cause: error, }); } return new TextGenerationError({ operation, detail: `${fallback}: ${error.message}`, cause: error, }); } return new TextGenerationError({ operation, detail: fallback, cause: error, });} function limitSection(value: string, maxChars: number): string { if (value.length <= maxChars) return value; const truncated = value.slice(0, maxChars); return `${truncated}\n\n[truncated]`;} 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";} 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); type MaterializedImageAttachments = { readonly imagePaths: ReadonlyArray<string>; }; const readStreamAsString = <E>( operation: string, stream: Stream.Stream<Uint8Array, E>, ): Effect.Effect<string, TextGenerationError> => Effect.gen(function* () { let text = ""; yield* Stream.runForEach(stream, (chunk) => Effect.sync(() => { text += Buffer.from(chunk).toString("utf8"); }), ).pipe( Effect.mapError((cause) => normalizeCodexError(operation, cause, "Failed to collect process output"), ), ); return text; }); const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const writeTempFile = ( operation: string, prefix: string, content: string, ): Effect.Effect<string, TextGenerationError> => { const filePath = path.join(tempDir, `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`); return fileSystem.writeFileString(filePath, content).pipe( Effect.mapError( (cause) => new TextGenerationError({ operation, detail: `Failed to write temp file at ${filePath}.`, cause, }), ), Effect.as(filePath), ); }; const safeUnlink = (filePath: string): Effect.Effect<void, never> => fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); const materializeImageAttachments = ( _operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", 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({ stateDir: serverConfig.stateDir, 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 = [], }: { operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; cwd: string; prompt: string; outputSchemaJson: S; imagePaths?: ReadonlyArray<string>; cleanupPaths?: ReadonlyArray<string>; }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => Effect.gen(function* () { const schemaPath = yield* writeTempFile( operation, "codex-schema", JSON.stringify(toCodexOutputJsonSchema(outputSchemaJson)), ); const outputPath = yield* writeTempFile(operation, "codex-output", ""); const runCodexCommand = Effect.gen(function* () { const command = ChildProcess.make( "codex", [ "exec", "--ephemeral", "-s", "read-only", "--model", CODEX_MODEL, "--config", `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`, "--output-schema", schemaPath, "--output-last-message", outputPath, ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), "-", ], { cwd, shell: process.platform === "win32", stdin: { stream: Stream.make(new TextEncoder().encode(prompt)), }, }, ); const child = yield* commandSpawner .spawn(command) .pipe( Effect.mapError((cause) => normalizeCodexError(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.map((value) => Number(value)), Effect.mapError((cause) => normalizeCodexError(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"] = (input) => { const wantsBranch = input.includeBranch === true; const prompt = [ "You write concise git commit messages.", wantsBranch ? "Return a JSON object with keys: subject, body, branch." : "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", ...(wantsBranch ? ["- branch must be a short semantic git branch fragment for this change"] : []), "- 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 outputSchemaJson = wantsBranch ? Schema.Struct({ subject: Schema.String, body: Schema.String, branch: Schema.String, }) : Schema.Struct({ subject: Schema.String, body: Schema.String, }); return runCodexJson({ operation: "generateCommitMessage", cwd: input.cwd, prompt, outputSchemaJson, }).pipe( Effect.map( (generated) => ({ subject: sanitizeCommitSubject(generated.subject), body: generated.body.trim(), ...("branch" in generated && typeof generated.branch === "string" ? { branch: sanitizeFeatureBranchName(generated.branch) } : {}), }) satisfies CommitMessageGenerationResult, ), ); }; const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => { 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"); return runCodexJson({ operation: "generatePrContent", cwd: input.cwd, prompt, outputSchemaJson: Schema.Struct({ title: Schema.String, body: Schema.String, }), }).pipe( Effect.map( (generated) => ({ title: sanitizePrTitle(generated.title), body: generated.body.trim(), }) satisfies PrContentGenerationResult, ), ); }; const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { return Effect.gen(function* () { const { imagePaths } = yield* materializeImageAttachments( "generateBranchName", input.attachments, ); const attachmentLines = (input.attachments ?? []).map( (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, ); const promptSections = [ "You generate concise git branch names.", "Return a JSON object with key: branch.", "Rules:", "- Branch should describe the requested work from the user message.", "- Keep it short and specific (2-6 words).", "- Use plain words only, no issue prefixes and no punctuation-heavy text.", "- If images are attached, use them as primary context for visual/UI issues.", "", "User message:", limitSection(input.message, 8_000), ]; if (attachmentLines.length > 0) { promptSections.push( "", "Attachment metadata:", limitSection(attachmentLines.join("\n"), 4_000), ); } const prompt = promptSections.join("\n"); const generated = yield* runCodexJson({ operation: "generateBranchName", cwd: input.cwd, prompt, outputSchemaJson: Schema.Struct({ branch: Schema.String, }), imagePaths, }); return { branch: sanitizeBranchFragment(generated.branch), } satisfies BranchNameGenerationResult; }); }; return { generateCommitMessage, generatePrContent, generateBranchName, } satisfies TextGenerationShape;}); export const CodexTextGenerationLive = Layer.effect(TextGeneration, makeCodexTextGeneration);
import { randomUUID } from "node:crypto";
import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
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 BranchNameGenerationResult,
type CommitMessageGenerationResult,
type PrContentGenerationResult,
type TextGenerationShape,
TextGeneration,
} from "../Services/TextGeneration.ts";
const CODEX_MODEL = "gpt-5.3-codex";
const CODEX_REASONING_EFFORT = "low";
const CODEX_TIMEOUT_MS = 180_000;
function toCodexOutputJsonSchema(schema: Schema.Top): unknown {
const document = Schema.toJsonSchemaDocument(schema);
if (document.definitions && Object.keys(document.definitions).length > 0) {
return {
...document.schema,
$defs: document.definitions,
};
}
return document.schema;
}
function normalizeCodexError(
operation: string,
error: unknown,
fallback: string,
): TextGenerationError {
if (Schema.is(TextGenerationError)(error)) {
return error;
}
if (error instanceof Error) {
const lower = error.message.toLowerCase();
if (
error.message.includes("Command not found: codex") ||
lower.includes("spawn codex") ||
lower.includes("enoent")
) {
return new TextGenerationError({
operation,
detail: "Codex CLI (`codex`) is required but not available on PATH.",
cause: error,
});
}
return new TextGenerationError({
operation,
detail: `${fallback}: ${error.message}`,
cause: error,
});
}
return new TextGenerationError({
operation,
detail: fallback,
cause: error,
});
}
function limitSection(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
const truncated = value.slice(0, maxChars);
return `${truncated}\n\n[truncated]`;
}
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";
}
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);
type MaterializedImageAttachments = {
readonly imagePaths: ReadonlyArray<string>;
};
const readStreamAsString = <E>(
operation: string,
stream: Stream.Stream<Uint8Array, E>,
): Effect.Effect<string, TextGenerationError> =>
Effect.gen(function* () {
let text = "";
yield* Stream.runForEach(stream, (chunk) =>
Effect.sync(() => {
text += Buffer.from(chunk).toString("utf8");
}),
).pipe(
Effect.mapError((cause) =>
normalizeCodexError(operation, cause, "Failed to collect process output"),
),
);
return text;
});
const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
const writeTempFile = (
operation: string,
prefix: string,
content: string,
): Effect.Effect<string, TextGenerationError> => {
const filePath = path.join(tempDir, `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`);
return fileSystem.writeFileString(filePath, content).pipe(
Effect.mapError(
(cause) =>
new TextGenerationError({
operation,
detail: `Failed to write temp file at ${filePath}.`,
cause,
}),
),
Effect.as(filePath),
);
};
const safeUnlink = (filePath: string): Effect.Effect<void, never> =>
fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void));
const materializeImageAttachments = (
_operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName",
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({
stateDir: serverConfig.stateDir,
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 = [],
}: {
operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
cwd: string;
prompt: string;
outputSchemaJson: S;
imagePaths?: ReadonlyArray<string>;
cleanupPaths?: ReadonlyArray<string>;
}): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
Effect.gen(function* () {
const schemaPath = yield* writeTempFile(
operation,
"codex-schema",
JSON.stringify(toCodexOutputJsonSchema(outputSchemaJson)),
);
const outputPath = yield* writeTempFile(operation, "codex-output", "");
const runCodexCommand = Effect.gen(function* () {
const command = ChildProcess.make(
"codex",
[
"exec",
"--ephemeral",
"-s",
"read-only",
"--model",
CODEX_MODEL,
"--config",
`model_reasoning_effort="${CODEX_REASONING_EFFORT}"`,
"--output-schema",
schemaPath,
"--output-last-message",
outputPath,
...imagePaths.flatMap((imagePath) => ["--image", imagePath]),
"-",
],
{
cwd,
shell: process.platform === "win32",
stdin: {
stream: Stream.make(new TextEncoder().encode(prompt)),
},
},
);
const child = yield* commandSpawner
.spawn(command)
.pipe(
Effect.mapError((cause) =>
normalizeCodexError(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.map((value) => Number(value)),
Effect.mapError((cause) =>
normalizeCodexError(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"] = (input) => {
const wantsBranch = input.includeBranch === true;
const prompt = [
"You write concise git commit messages.",
wantsBranch
? "Return a JSON object with keys: subject, body, branch."
: "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",
...(wantsBranch
? ["- branch must be a short semantic git branch fragment for this change"]
: []),
"- 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 outputSchemaJson = wantsBranch
? Schema.Struct({
subject: Schema.String,
body: Schema.String,
branch: Schema.String,
})
: Schema.Struct({
subject: Schema.String,
body: Schema.String,
});
return runCodexJson({
operation: "generateCommitMessage",
cwd: input.cwd,
prompt,
outputSchemaJson,
}).pipe(
Effect.map(
(generated) =>
({
subject: sanitizeCommitSubject(generated.subject),
body: generated.body.trim(),
...("branch" in generated && typeof generated.branch === "string"
? { branch: sanitizeFeatureBranchName(generated.branch) }
: {}),
}) satisfies CommitMessageGenerationResult,
),
);
};
const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => {
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");
return runCodexJson({
operation: "generatePrContent",
cwd: input.cwd,
prompt,
outputSchemaJson: Schema.Struct({
title: Schema.String,
body: Schema.String,
}),
}).pipe(
Effect.map(
(generated) =>
({
title: sanitizePrTitle(generated.title),
body: generated.body.trim(),
}) satisfies PrContentGenerationResult,
),
);
};
const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {
return Effect.gen(function* () {
const { imagePaths } = yield* materializeImageAttachments(
"generateBranchName",
input.attachments,
);
const attachmentLines = (input.attachments ?? []).map(
(attachment) =>
`- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
);
const promptSections = [
"You generate concise git branch names.",
"Return a JSON object with key: branch.",
"Rules:",
"- Branch should describe the requested work from the user message.",
"- Keep it short and specific (2-6 words).",
"- Use plain words only, no issue prefixes and no punctuation-heavy text.",
"- If images are attached, use them as primary context for visual/UI issues.",
"",
"User message:",
limitSection(input.message, 8_000),
];
if (attachmentLines.length > 0) {
promptSections.push(
"",
"Attachment metadata:",
limitSection(attachmentLines.join("\n"), 4_000),
);
}
const prompt = promptSections.join("\n");
const generated = yield* runCodexJson({
operation: "generateBranchName",
cwd: input.cwd,
prompt,
outputSchemaJson: Schema.Struct({
branch: Schema.String,
}),
imagePaths,
});
return {
branch: sanitizeBranchFragment(generated.branch),
} satisfies BranchNameGenerationResult;
});
};
return {
generateCommitMessage,
generatePrContent,
generateBranchName,
} satisfies TextGenerationShape;
});
export const CodexTextGenerationLive = Layer.effect(TextGeneration, makeCodexTextGeneration);