1import { TextGenerationError } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4
5const isTextGenerationError = Schema.is(TextGenerationError);
6const 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. */
11export 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. */
26export 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. */
33export 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. */
47export 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.
57const MAX_THREAD_TITLE_CHARS = 120;
58
59/** Normalise a raw thread title to a single line. Clients truncate for display. */
60export 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`)" */
84function 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 */
94export 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}