Open History to see the last commit
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
10export function toJsonSchemaObject(schema: Schema.Top): unknown {
11 const document = Schema.toJsonSchemaDocument(Schema.toType(schema), {
12 onExcessProperty: "error",
13 });
14 if (document.definitions && Object.keys(document.definitions).length > 0) {
15 return { ...document.schema, $defs: document.definitions };
16 }
17 return document.schema;
18}
19
20export function limitSection(value: string, maxChars: number): string {
21 if (value.length <= maxChars) return value;
22 // Half an emoji serializes to a lone surrogate escape, which provider JSON parsers reject.
23 const last = value.charCodeAt(maxChars - 1);
24 const truncated = value.slice(0, last >= 0xd800 && last <= 0xdbff ? maxChars - 1 : maxChars);
25 return `${truncated}\n\n[truncated]`;
26}
27
28export function sanitizeCommitSubject(raw: string): string {
29 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
30 const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim();
31 if (withoutTrailingPeriod.length === 0) {
32 return "Update project files";
33 }
34
35 if (withoutTrailingPeriod.length <= 72) {
36 return withoutTrailingPeriod;
37 }
38 const segments = new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(
39 withoutTrailingPeriod,
40 );
41 let subject = "";
42 for (const { segment } of segments) {
43 if (subject.length + segment.length > 72) break;
44 subject += segment;
45 }
46 return subject.trimEnd() || "Update project files";
47}
48
49export function sanitizePrTitle(raw: string): string {
50 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
51 if (singleLine.length > 0) {
52 return singleLine;
53 }
54 return "Update project changes";
55}
56
57const MAX_THREAD_TITLE_CHARS = 120;
58
59export function sanitizeThreadTitle(raw: string): string {
60 // Unwrap a JSON-formatted title before truncation can cut off the closing brace.
61 const decoded = decodeJsonThreadTitle(raw);
62 const title = Option.isSome(decoded) ? decoded.value.title : raw;
63 const normalized = title
64 .trim()
65 .split(/\r?\n/g)[0]
66 ?.trim()
67 .replace(/^['"`]+|['"`]+$/g, "")
68 .trim()
69 .replace(/\s+/g, " ");
70
71 if (!normalized || normalized.trim().length === 0) {
72 return "New thread";
73 }
74
75 if (normalized.length <= MAX_THREAD_TITLE_CHARS) {
76 return normalized;
77 }
78
79 let end = MAX_THREAD_TITLE_CHARS - 3;
80 const previous = normalized.charCodeAt(end - 1);
81 const next = normalized.charCodeAt(end);
82 if (previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
83 end -= 1;
84 }
85 return `${normalized.slice(0, end).trimEnd()}...`;
86}
87
88function cliLabel(cliName: string): string {
89 const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1);
90 return `${capitalized} CLI (\`${cliName}\`)`;
91}
92
93export function normalizeCliError(
94 cliName: string,
95 operation: string,
96 error: unknown,
97 fallback: string,
98): TextGenerationError {
99 if (isTextGenerationError(error)) {
100 return error;
101 }
102
103 if (error instanceof Error) {
104 const lower = error.message.toLowerCase();
105 if (
106 error.message.includes(`Command not found: ${cliName}`) ||
107 lower.includes(`spawn ${cliName}`) ||
108 lower.includes("enoent")
109 ) {
110 return new TextGenerationError({
111 operation,
112 detail: `${cliLabel(cliName)} is required but not available on PATH.`,
113 cause: error,
114 });
115 }
116 return new TextGenerationError({
117 operation,
118 detail: fallback,
119 cause: error,
120 });
121 }
122
123 return new TextGenerationError({
124 operation,
125 detail: fallback,
126 cause: error,
127 });
128}