import { TextGenerationError } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; const isTextGenerationError = Schema.is(TextGenerationError); const decodeJsonThreadTitle = Schema.decodeOption( Schema.fromJsonString(Schema.Struct({ title: Schema.String })), ); /** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ export function toJsonSchemaObject(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; } /** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ export function limitSection(value: string, maxChars: number): string { if (value.length <= maxChars) return value; const truncated = value.slice(0, maxChars); return `${truncated}\n\n[truncated]`; } /** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ export 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(); } /** Normalise a raw PR title to a single line with a sensible fallback. */ export 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"; } /** Normalise a raw thread title to a compact single-line sidebar-safe label. */ export function sanitizeThreadTitle(raw: string): string { // Unwrap a JSON-formatted title before truncation can cut off the closing brace. const decoded = decodeJsonThreadTitle(raw); const title = Option.isSome(decoded) ? decoded.value.title : raw; const normalized = title .trim() .split(/\r?\n/g)[0] ?.trim() .replace(/^['"`]+|['"`]+$/g, "") .trim() .replace(/\s+/g, " "); if (!normalized || normalized.trim().length === 0) { return "New thread"; } if (normalized.length <= 50) { return normalized; } return `${normalized.slice(0, 47).trimEnd()}...`; } /** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ function cliLabel(cliName: string): string { const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); return `${capitalized} CLI (\`${cliName}\`)`; } /** * Normalize an unknown error from a CLI text generation process into a * typed `TextGenerationError`. Parameterized by CLI name so both Codex * and Claude (and future providers) can share the same logic. */ export function normalizeCliError( cliName: string, operation: string, error: unknown, fallback: string, ): TextGenerationError { if (isTextGenerationError(error)) { return error; } if (error instanceof Error) { const lower = error.message.toLowerCase(); if ( error.message.includes(`Command not found: ${cliName}`) || lower.includes(`spawn ${cliName}`) || lower.includes("enoent") ) { return new TextGenerationError({ operation, detail: `${cliLabel(cliName)} is required but not available on PATH.`, cause: error, }); } return new TextGenerationError({ operation, detail: fallback, cause: error, }); } return new TextGenerationError({ operation, detail: fallback, cause: error, }); }