| 1 | import { TextGenerationError } from "@t3tools/contracts"; |
| 2 | import * as Option from "effect/Option"; |
| 3 | import * as Schema from "effect/Schema"; |
| 4 | |
| 5 | const isTextGenerationError = Schema.is(TextGenerationError); |
| 6 | const decodeJsonThreadTitle = Schema.decodeOption( |
| 7 | Schema.fromJsonString(Schema.Struct({ title: Schema.String })), |
| 8 | ); |
| 9 | |
| 10 | /** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ |
| 11 | export function toJsonSchemaObject(schema: Schema.Top): unknown { |
| 12 | // The type side, so decoding defaults do not turn required fields into |
| 13 | // optional ones, and closed objects (`additionalProperties: false`): |
| 14 | // structured-output modes require both, and closed was the generator |
| 15 | // default before effect rc.113. |
| 16 | const document = Schema.toJsonSchemaDocument(Schema.toType(schema), { |
| 17 | onExcessProperty: "error", |
| 18 | }); |
| 19 | if (document.definitions && Object.keys(document.definitions).length > 0) { |
| 20 | return { ...document.schema, $defs: document.definitions }; |
| 21 | } |
| 22 | return document.schema; |
| 23 | } |
| 24 | |
| 25 | /** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ |
| 26 | export function limitSection(value: string, maxChars: number): string { |
| 27 | if (value.length <= maxChars) return value; |
| 28 | const truncated = value.slice(0, maxChars); |
| 29 | return `${truncated}\n\n[truncated]`; |
| 30 | } |
| 31 | |
| 32 | /** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ |
| 33 | export function sanitizeCommitSubject(raw: string): string { |
| 34 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; |
| 35 | const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); |
| 36 | if (withoutTrailingPeriod.length === 0) { |
| 37 | return "Update project files"; |
| 38 | } |
| 39 | |
| 40 | if (withoutTrailingPeriod.length <= 72) { |
| 41 | return withoutTrailingPeriod; |
| 42 | } |
| 43 | return withoutTrailingPeriod.slice(0, 72).trimEnd(); |
| 44 | } |
| 45 | |
| 46 | /** Normalise a raw PR title to a single line with a sensible fallback. */ |
| 47 | export function sanitizePrTitle(raw: string): string { |
| 48 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; |
| 49 | if (singleLine.length > 0) { |
| 50 | return singleLine; |
| 51 | } |
| 52 | return "Update project changes"; |
| 53 | } |
| 54 | |
| 55 | // Prompts ask for under 40 characters. This cap only stops a runaway model |
| 56 | // from pushing a paragraph into the sidebar, header, and window title. |
| 57 | const MAX_THREAD_TITLE_CHARS = 120; |
| 58 | |
| 59 | /** Normalise a raw thread title to a single line. Clients truncate for display. */ |
| 60 | export function sanitizeThreadTitle(raw: string): string { |
| 61 | // Unwrap a JSON-formatted title before truncation can cut off the closing brace. |
| 62 | const decoded = decodeJsonThreadTitle(raw); |
| 63 | const title = Option.isSome(decoded) ? decoded.value.title : raw; |
| 64 | const normalized = title |
| 65 | .trim() |
| 66 | .split(/\r?\n/g)[0] |
| 67 | ?.trim() |
| 68 | .replace(/^['"`]+|['"`]+$/g, "") |
| 69 | .trim() |
| 70 | .replace(/\s+/g, " "); |
| 71 | |
| 72 | if (!normalized || normalized.trim().length === 0) { |
| 73 | return "New thread"; |
| 74 | } |
| 75 | |
| 76 | if (normalized.length <= MAX_THREAD_TITLE_CHARS) { |
| 77 | return normalized; |
| 78 | } |
| 79 | |
| 80 | return `${normalized.slice(0, MAX_THREAD_TITLE_CHARS - 3).trimEnd()}...`; |
| 81 | } |
| 82 | |
| 83 | /** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ |
| 84 | function cliLabel(cliName: string): string { |
| 85 | const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); |
| 86 | return `${capitalized} CLI (\`${cliName}\`)`; |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Normalize an unknown error from a CLI text generation process into a |
| 91 | * typed `TextGenerationError`. Parameterized by CLI name so both Codex |
| 92 | * and Claude (and future providers) can share the same logic. |
| 93 | */ |
| 94 | export function normalizeCliError( |
| 95 | cliName: string, |
| 96 | operation: string, |
| 97 | error: unknown, |
| 98 | fallback: string, |
| 99 | ): TextGenerationError { |
| 100 | if (isTextGenerationError(error)) { |
| 101 | return error; |
| 102 | } |
| 103 | |
| 104 | if (error instanceof Error) { |
| 105 | const lower = error.message.toLowerCase(); |
| 106 | if ( |
| 107 | error.message.includes(`Command not found: ${cliName}`) || |
| 108 | lower.includes(`spawn ${cliName}`) || |
| 109 | lower.includes("enoent") |
| 110 | ) { |
| 111 | return new TextGenerationError({ |
| 112 | operation, |
| 113 | detail: `${cliLabel(cliName)} is required but not available on PATH.`, |
| 114 | cause: error, |
| 115 | }); |
| 116 | } |
| 117 | return new TextGenerationError({ |
| 118 | operation, |
| 119 | detail: fallback, |
| 120 | cause: error, |
| 121 | }); |
| 122 | } |
| 123 | |
| 124 | return new TextGenerationError({ |
| 125 | operation, |
| 126 | detail: fallback, |
| 127 | cause: error, |
| 128 | }); |
| 129 | } |