stage-tui 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,706 @@
1
+ import { createCerebras } from "@ai-sdk/cerebras"
2
+ import { generateText, jsonSchema, NoObjectGeneratedError, Output } from "ai"
3
+
4
+ import type { StageConfig } from "./config"
5
+ import type { ChangedFile, GitClient } from "./git"
6
+
7
+ const COMMIT_TYPES = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"] as const
8
+ type CommitType = (typeof COMMIT_TYPES)[number]
9
+ const SCOPE_REGEX = /^[a-z0-9._/-]+$/
10
+ const MAX_RECENT_COMMIT_SUBJECTS = 6
11
+
12
+ const CONVENTIONAL_COMMIT_REGEX =
13
+ /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._/-]+\))?!?: [^A-Z].+$/
14
+
15
+ const COMMIT_OUTPUT_SCHEMA = {
16
+ type: "object",
17
+ additionalProperties: false,
18
+ required: ["type", "description"],
19
+ properties: {
20
+ type: {
21
+ type: "string",
22
+ enum: COMMIT_TYPES,
23
+ description: "Conventional commit type based on behavior impact.",
24
+ },
25
+ scope: {
26
+ type: "string",
27
+ description: "Optional subsystem noun such as ui, git, config, keyboard.",
28
+ },
29
+ description: {
30
+ type: "string",
31
+ description: "Imperative, concise summary of what changed.",
32
+ },
33
+ },
34
+ } as const
35
+
36
+ const COMMIT_OUTPUT_FLEXIBLE_SCHEMA = jsonSchema<{
37
+ type: CommitType
38
+ scope?: string
39
+ description: string
40
+ }>(COMMIT_OUTPUT_SCHEMA)
41
+
42
+ type GenerateAiCommitSummaryParams = {
43
+ git: GitClient
44
+ files: ChangedFile[]
45
+ selectedPaths: string[]
46
+ aiConfig: StageConfig["ai"]
47
+ }
48
+
49
+ type TextGenerationModel = Parameters<typeof generateText>[0]["model"]
50
+
51
+ export async function generateAiCommitSummary({
52
+ git,
53
+ files,
54
+ selectedPaths,
55
+ aiConfig,
56
+ }: GenerateAiCommitSummaryParams): Promise<string> {
57
+ if (!aiConfig.enabled) {
58
+ throw new Error("AI commit generation is disabled in config.")
59
+ }
60
+ if (aiConfig.provider !== "cerebras") {
61
+ throw new Error(`Unsupported AI provider: ${aiConfig.provider}`)
62
+ }
63
+ if (!aiConfig.apiKey.trim()) {
64
+ throw new Error("AI commit generation requires ai.api_key.")
65
+ }
66
+
67
+ const selected = selectedPaths.map((path) => path.trim()).filter(Boolean)
68
+ if (selected.length === 0) {
69
+ throw new Error("No files selected for AI commit.")
70
+ }
71
+
72
+ const fileByPath = new Map(files.map((file) => [file.path, file]))
73
+ const context = await buildCommitContext({
74
+ git,
75
+ fileByPath,
76
+ selectedPaths: selected,
77
+ maxFiles: aiConfig.maxFiles,
78
+ maxCharsPerFile: aiConfig.maxCharsPerFile,
79
+ })
80
+
81
+ const cerebras = createCerebras({
82
+ apiKey: aiConfig.apiKey,
83
+ })
84
+ const model = cerebras(aiConfig.model)
85
+ const firstAttempt = await generateCommitDraft(model, context, aiConfig.reasoningEffort, false)
86
+ const firstValid = finalizeCommitSummary(firstAttempt)
87
+ if (firstValid) {
88
+ return firstValid
89
+ }
90
+
91
+ const secondAttempt = await generateCommitDraft(model, context, aiConfig.reasoningEffort, true)
92
+ const secondValid = finalizeCommitSummary(secondAttempt)
93
+ if (secondValid) {
94
+ return secondValid
95
+ }
96
+
97
+ throw new Error(
98
+ "AI did not return a usable conventional commit message. Try again or adjust ai.reasoning_effort / ai.max_chars_per_file.",
99
+ )
100
+ }
101
+
102
+ type BuildCommitContextParams = {
103
+ git: GitClient
104
+ fileByPath: Map<string, ChangedFile>
105
+ selectedPaths: string[]
106
+ maxFiles: number
107
+ maxCharsPerFile: number
108
+ }
109
+
110
+ async function buildCommitContext({
111
+ git,
112
+ fileByPath,
113
+ selectedPaths,
114
+ maxFiles,
115
+ maxCharsPerFile,
116
+ }: BuildCommitContextParams): Promise<string> {
117
+ const limitedPaths = selectedPaths.slice(0, maxFiles)
118
+ const signals: ContextSignals = {
119
+ touchedFiles: limitedPaths.length,
120
+ newFiles: 0,
121
+ modifiedFiles: 0,
122
+ deletedFiles: 0,
123
+ renamedFiles: 0,
124
+ addedLines: 0,
125
+ removedLines: 0,
126
+ docsFiles: 0,
127
+ testFiles: 0,
128
+ configFiles: 0,
129
+ }
130
+
131
+ const fileSummaries = limitedPaths.map((path) => {
132
+ const file = fileByPath.get(path)
133
+ const status = file ? `${file.indexStatus}${file.worktreeStatus}`.trim() || "??" : "??"
134
+ updateStatusSignals(signals, file)
135
+ updatePathCategorySignals(signals, path)
136
+ return { path, status }
137
+ })
138
+
139
+ const snippets = await Promise.all(limitedPaths.map(async (path) => {
140
+ const diff = await git.diffForFile(path)
141
+ const diffStats = analyzeDiff(diff)
142
+ const behaviorCues = collectBehaviorCues(diff)
143
+ const condensed = condenseDiff(diff, maxCharsPerFile)
144
+ return {
145
+ path,
146
+ addedLines: diffStats.addedLines,
147
+ removedLines: diffStats.removedLines,
148
+ behaviorCues,
149
+ condensed,
150
+ }
151
+ }))
152
+
153
+ const fileLines = fileSummaries.map((entry) => {
154
+ const snippet = snippets.find((candidate) => candidate.path === entry.path)
155
+ const additions = snippet?.addedLines ?? 0
156
+ const deletions = snippet?.removedLines ?? 0
157
+ signals.addedLines += additions
158
+ signals.removedLines += deletions
159
+ return `- ${entry.status} ${entry.path} (+${additions} -${deletions})`
160
+ })
161
+
162
+ const diffHighlights = snippets
163
+ .filter((snippet) => snippet.condensed)
164
+ .map((snippet) => `FILE: ${snippet.path}\n${snippet.condensed}`)
165
+
166
+ const behaviorCues = aggregateBehaviorCues(snippets.map((snippet) => snippet.behaviorCues))
167
+ const recentCommitSubjects = await readRecentCommitSubjects(git)
168
+ const existingSurfaceOnly = signals.newFiles === 0 && signals.renamedFiles === 0
169
+ const likelyNewSurface = signals.newFiles > 0 || signals.renamedFiles > 0
170
+
171
+ const recentCommitsSection = recentCommitSubjects.length > 0
172
+ ? [
173
+ "",
174
+ "Recent commit subjects (style reference only):",
175
+ ...recentCommitSubjects.map((subject) => `- ${subject}`),
176
+ ]
177
+ : []
178
+
179
+ const diffSection = diffHighlights.length > 0
180
+ ? diffHighlights.join("\n\n")
181
+ : "- no diff snippets were captured"
182
+
183
+ const selectedPathsSection = selectedPaths.length > limitedPaths.length
184
+ ? `- additional_selected_files_not_shown: ${selectedPaths.length - limitedPaths.length}`
185
+ : "- additional_selected_files_not_shown: 0"
186
+
187
+ const lines: string[] = [
188
+ "Context signals:",
189
+ `- touched_files: ${signals.touchedFiles}`,
190
+ `- existing_surface_only: ${existingSurfaceOnly ? "yes" : "no"}`,
191
+ `- likely_new_surface: ${likelyNewSurface ? "yes" : "no"}`,
192
+ `- status_counts: new=${signals.newFiles} modified=${signals.modifiedFiles} deleted=${signals.deletedFiles} renamed=${signals.renamedFiles}`,
193
+ `- diff_line_counts: additions=${signals.addedLines} deletions=${signals.removedLines}`,
194
+ `- file_categories: docs=${signals.docsFiles} tests=${signals.testFiles} config=${signals.configFiles}`,
195
+ selectedPathsSection,
196
+ "- classify by behavior impact first; line counts and file counts are supporting signals",
197
+ "",
198
+ "Behavior cues:",
199
+ `- added_conditions: ${formatCueList(behaviorCues.addedConditions)}`,
200
+ `- removed_conditions: ${formatCueList(behaviorCues.removedConditions)}`,
201
+ `- added_guards: ${formatCueList(behaviorCues.addedGuards)}`,
202
+ `- removed_guards: ${formatCueList(behaviorCues.removedGuards)}`,
203
+ "",
204
+ "Changed files:",
205
+ fileLines.join("\n"),
206
+ ...recentCommitsSection,
207
+ "",
208
+ "Diff highlights:",
209
+ diffSection,
210
+ ]
211
+
212
+ return lines.join("\n")
213
+ }
214
+
215
+ async function readRecentCommitSubjects(git: GitClient): Promise<string[]> {
216
+ try {
217
+ const entries = await git.listCommits(MAX_RECENT_COMMIT_SUBJECTS)
218
+ return entries
219
+ .map((entry) => entry.subject.trim())
220
+ .filter(Boolean)
221
+ .slice(0, MAX_RECENT_COMMIT_SUBJECTS)
222
+ } catch {
223
+ return []
224
+ }
225
+ }
226
+
227
+ function condenseDiff(diff: string, maxChars: number): string {
228
+ const lines = diff.split("\n")
229
+ const relevant: string[] = []
230
+ let changedLines = 0
231
+ let hunkCount = 0
232
+ const MAX_CHANGED_LINES = 120
233
+ const MAX_HUNKS = 12
234
+
235
+ for (const line of lines) {
236
+ if (line.startsWith("# ")) {
237
+ relevant.push(line)
238
+ continue
239
+ }
240
+ if (line.startsWith("@@")) {
241
+ hunkCount += 1
242
+ if (hunkCount <= MAX_HUNKS) {
243
+ relevant.push(line)
244
+ }
245
+ continue
246
+ }
247
+ if (line.startsWith("+++") || line.startsWith("---")) {
248
+ continue
249
+ }
250
+ if (line.startsWith("+") || line.startsWith("-")) {
251
+ changedLines += 1
252
+ if (changedLines <= MAX_CHANGED_LINES) {
253
+ relevant.push(line)
254
+ }
255
+ }
256
+ }
257
+
258
+ const body = (relevant.length > 0 ? relevant.join("\n") : diff.trim()).trim()
259
+ if (!body) {
260
+ return ""
261
+ }
262
+ if (body.length <= maxChars) {
263
+ return body
264
+ }
265
+ return `${body.slice(0, Math.max(maxChars - 16, 1)).trimEnd()}\n...[truncated]`
266
+ }
267
+
268
+ async function generateCommitDraft(
269
+ model: TextGenerationModel,
270
+ context: string,
271
+ reasoningEffort: "low" | "medium" | "high",
272
+ retry: boolean,
273
+ ): Promise<CommitDraft | null> {
274
+ const maxOutputTokens = resolveMaxOutputTokens(reasoningEffort)
275
+ try {
276
+ const { output } = await generateText({
277
+ model,
278
+ temperature: 0,
279
+ maxOutputTokens,
280
+ providerOptions: {
281
+ cerebras: {
282
+ reasoningEffort,
283
+ strictJsonSchema: true,
284
+ },
285
+ },
286
+ output: Output.object({
287
+ name: "conventional_commit_subject",
288
+ description: "Conventional commit fields for a concise git subject line.",
289
+ schema: COMMIT_OUTPUT_FLEXIBLE_SCHEMA,
290
+ }),
291
+ system: buildSystemPrompt(retry),
292
+ prompt: buildUserPrompt(context, retry),
293
+ })
294
+
295
+ return normalizeCommitDraft(output)
296
+ } catch (error) {
297
+ if (NoObjectGeneratedError.isInstance(error)) {
298
+ const fallbackText = typeof error.text === "string" ? error.text : ""
299
+ return normalizeCommitDraft(extractJsonObject(fallbackText))
300
+ }
301
+ throw error
302
+ }
303
+ }
304
+
305
+ function buildSystemPrompt(retry: boolean): string {
306
+ return [
307
+ "You generate conventional commit subjects from git diff context.",
308
+ "Prioritize semantic correctness over wording novelty.",
309
+ "Commit type rubric:",
310
+ "- fix: behavior correction, bug handling, regression prevention, compatibility adjustment.",
311
+ "- feat: net-new user-facing capability or clearly new surface (new command/screen/endpoint/setting).",
312
+ "- refactor: structural changes with no behavior change.",
313
+ "- style: formatting-only edits.",
314
+ "- docs/test/build/ci/chore/perf/revert only when clearly dominant.",
315
+ "- if uncertain between feat and fix, choose fix.",
316
+ "- do not infer feat only from additions, support wording, or larger diff size.",
317
+ "- if changes are in existing files/flows and no new surface is explicit, avoid feat.",
318
+ "- adding compatibility or alternate paths for an existing workflow is usually fix.",
319
+ "- feat requires introducing a meaningfully new workflow/surface to users.",
320
+ "- when conditions/guards are added to prevent unsafe or accidental behavior, this strongly indicates fix.",
321
+ "Style rules:",
322
+ "- scope is optional and must be a lowercase noun token.",
323
+ "- description is imperative, specific, concise, and starts lowercase.",
324
+ "- description must read naturally after '<type>(<scope>):'.",
325
+ "- for fix, phrase the user-visible failure prevented/resolved (often with 'when' or 'on').",
326
+ "- for fix, prefer describing the undesired side effect being prevented.",
327
+ "- for fix, prefer prevent/avoid/handle over enable/add.",
328
+ "- prefer concrete verbs: fix/prevent/handle/enable/avoid/normalize/simplify/refine.",
329
+ "- prefer user-visible behavior over internal API detail names.",
330
+ "- avoid wording that names low-level implementation calls unless unavoidable.",
331
+ "- avoid vague lead verbs like support, update, improve, change.",
332
+ "- avoid 'enable' for fix unless the phrase also states what broken behavior is corrected.",
333
+ "- avoid generic phrases like 'update code' or 'improve things'.",
334
+ retry
335
+ ? "Retry mode: if previous output was invalid, simplify wording while keeping meaning."
336
+ : "Return the best single conventional commit subject metadata for this change set.",
337
+ ].join("\n")
338
+ }
339
+
340
+ function buildUserPrompt(context: string, retry: boolean): string {
341
+ const retryLine = retry
342
+ ? "Retry constraints: keep description short and concrete; prefer simpler scope or omit scope."
343
+ : "Use the context below."
344
+ return [
345
+ retryLine,
346
+ "Output must satisfy schema and conventional commit semantics.",
347
+ "",
348
+ context,
349
+ ].join("\n")
350
+ }
351
+
352
+ type CommitDraft = {
353
+ type: CommitType
354
+ scope?: string
355
+ description: string
356
+ }
357
+
358
+ function normalizeCommitDraft(value: unknown): CommitDraft | null {
359
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
360
+ return null
361
+ }
362
+
363
+ const candidate = value as Record<string, unknown>
364
+ const type = typeof candidate.type === "string" ? candidate.type.trim().toLowerCase() : ""
365
+ if (!isCommitType(type)) {
366
+ return null
367
+ }
368
+
369
+ const descriptionRaw = typeof candidate.description === "string" ? candidate.description : ""
370
+ const description = sanitizeDescription(descriptionRaw)
371
+ if (!description) {
372
+ return null
373
+ }
374
+
375
+ const scopeRaw = typeof candidate.scope === "string" ? candidate.scope : undefined
376
+ const scope = sanitizeScope(scopeRaw)
377
+
378
+ return {
379
+ type,
380
+ scope,
381
+ description,
382
+ }
383
+ }
384
+
385
+ function isCommitType(value: string): value is CommitType {
386
+ return COMMIT_TYPES.includes(value as CommitType)
387
+ }
388
+
389
+ function sanitizeScope(scope: string | undefined): string | undefined {
390
+ if (!scope) {
391
+ return undefined
392
+ }
393
+ const normalized = scope.trim().toLowerCase()
394
+ if (!normalized) {
395
+ return undefined
396
+ }
397
+ if (!SCOPE_REGEX.test(normalized)) {
398
+ return undefined
399
+ }
400
+ return normalized
401
+ }
402
+
403
+ function sanitizeDescription(description: string): string {
404
+ const normalized = description
405
+ .trim()
406
+ .replace(/^["'`]+/, "")
407
+ .replace(/["'`]+$/, "")
408
+ .replace(/[‐‑–—]/g, "-")
409
+ .replace(/\s+/g, " ")
410
+ .replace(/[.]+$/, "")
411
+ .trim()
412
+
413
+ if (!normalized) {
414
+ return ""
415
+ }
416
+
417
+ const first = normalized[0]
418
+ const loweredFirst = first ? first.toLowerCase() : ""
419
+ return `${loweredFirst}${normalized.slice(1)}`
420
+ }
421
+
422
+ function finalizeCommitSummary(draft: CommitDraft | null): string | null {
423
+ if (!draft) {
424
+ return null
425
+ }
426
+
427
+ const prefix = draft.scope ? `${draft.type}(${draft.scope})` : draft.type
428
+ const prefixWithColon = `${prefix}: `
429
+ const maxDescriptionLength = Math.max(72 - prefixWithColon.length, 1)
430
+ const compactDescription = compactDescriptionLength(draft.description, maxDescriptionLength)
431
+ const candidate = `${prefixWithColon}${compactDescription}`
432
+ if (!CONVENTIONAL_COMMIT_REGEX.test(candidate)) {
433
+ return null
434
+ }
435
+ return candidate
436
+ }
437
+
438
+ function compactDescriptionLength(description: string, maxLength: number): string {
439
+ if (description.length <= maxLength) {
440
+ return description
441
+ }
442
+
443
+ const clipped = description.slice(0, maxLength).trim()
444
+ const lastSpace = clipped.lastIndexOf(" ")
445
+ const wordSafe = lastSpace >= Math.floor(maxLength * 0.6) ? clipped.slice(0, lastSpace).trim() : clipped
446
+ return trimTrailingConnector(wordSafe.replace(/[.]+$/, "").trim())
447
+ }
448
+
449
+ function trimTrailingConnector(text: string): string {
450
+ const connectors = new Set(["and", "or", "to", "on", "with", "for", "of", "the", "a", "an", "via"])
451
+ const words = text.split(" ").filter(Boolean)
452
+ while (words.length > 1 && connectors.has(words[words.length - 1] ?? "")) {
453
+ words.pop()
454
+ }
455
+ return words.join(" ")
456
+ }
457
+
458
+ type ContextSignals = {
459
+ touchedFiles: number
460
+ newFiles: number
461
+ modifiedFiles: number
462
+ deletedFiles: number
463
+ renamedFiles: number
464
+ addedLines: number
465
+ removedLines: number
466
+ docsFiles: number
467
+ testFiles: number
468
+ configFiles: number
469
+ }
470
+
471
+ type BehaviorCues = {
472
+ addedConditions: string[]
473
+ removedConditions: string[]
474
+ addedGuards: string[]
475
+ removedGuards: string[]
476
+ }
477
+
478
+ function updateStatusSignals(signals: ContextSignals, file: ChangedFile | undefined): void {
479
+ if (!file) return
480
+ if (file.untracked) {
481
+ signals.newFiles += 1
482
+ return
483
+ }
484
+
485
+ const statuses = [file.indexStatus, file.worktreeStatus]
486
+ if (statuses.includes("D")) {
487
+ signals.deletedFiles += 1
488
+ return
489
+ }
490
+ if (statuses.includes("R")) {
491
+ signals.renamedFiles += 1
492
+ return
493
+ }
494
+ if (statuses.includes("A")) {
495
+ signals.newFiles += 1
496
+ return
497
+ }
498
+ signals.modifiedFiles += 1
499
+ }
500
+
501
+ function updatePathCategorySignals(signals: ContextSignals, path: string): void {
502
+ const normalized = path.toLowerCase()
503
+ if (isDocsPath(normalized)) signals.docsFiles += 1
504
+ if (isTestPath(normalized)) signals.testFiles += 1
505
+ if (isConfigPath(normalized)) signals.configFiles += 1
506
+ }
507
+
508
+ function isDocsPath(path: string): boolean {
509
+ return path.endsWith(".md") || path.endsWith(".mdx") || path.includes("/docs/") || path.startsWith("docs/")
510
+ }
511
+
512
+ function isTestPath(path: string): boolean {
513
+ return path.includes("/test/") || path.includes("/tests/") || path.includes(".test.") || path.includes(".spec.")
514
+ }
515
+
516
+ function isConfigPath(path: string): boolean {
517
+ return (
518
+ path.endsWith(".json")
519
+ || path.endsWith(".yaml")
520
+ || path.endsWith(".yml")
521
+ || path.endsWith(".toml")
522
+ || path.endsWith(".ini")
523
+ || path.endsWith("lock")
524
+ || path.endsWith(".lock")
525
+ )
526
+ }
527
+
528
+ function analyzeDiff(diff: string): { addedLines: number; removedLines: number } {
529
+ let addedLines = 0
530
+ let removedLines = 0
531
+
532
+ for (const line of diff.split("\n")) {
533
+ if (line.startsWith("+++")) continue
534
+ if (line.startsWith("---")) continue
535
+ if (line.startsWith("+")) {
536
+ addedLines += 1
537
+ continue
538
+ }
539
+ if (line.startsWith("-")) {
540
+ removedLines += 1
541
+ }
542
+ }
543
+
544
+ return { addedLines, removedLines }
545
+ }
546
+
547
+ function collectBehaviorCues(diff: string): BehaviorCues {
548
+ const addedConditions = new Set<string>()
549
+ const removedConditions = new Set<string>()
550
+ const addedGuards = new Set<string>()
551
+ const removedGuards = new Set<string>()
552
+
553
+ for (const line of diff.split("\n")) {
554
+ if (line.startsWith("+++")
555
+ || line.startsWith("---")
556
+ || line.startsWith("@@")
557
+ || line.startsWith("# ")) {
558
+ continue
559
+ }
560
+
561
+ const isAdded = line.startsWith("+")
562
+ const isRemoved = line.startsWith("-")
563
+ if (!isAdded && !isRemoved) {
564
+ continue
565
+ }
566
+
567
+ const content = line.slice(1).trim()
568
+ if (!content) {
569
+ continue
570
+ }
571
+
572
+ const condition = extractConditionCue(content)
573
+ if (condition) {
574
+ if (isAdded) {
575
+ addedConditions.add(condition)
576
+ } else {
577
+ removedConditions.add(condition)
578
+ }
579
+ }
580
+
581
+ const guard = extractGuardCue(content)
582
+ if (guard) {
583
+ if (isAdded) {
584
+ addedGuards.add(guard)
585
+ } else {
586
+ removedGuards.add(guard)
587
+ }
588
+ }
589
+ }
590
+
591
+ return {
592
+ addedConditions: Array.from(addedConditions),
593
+ removedConditions: Array.from(removedConditions),
594
+ addedGuards: Array.from(addedGuards),
595
+ removedGuards: Array.from(removedGuards),
596
+ }
597
+ }
598
+
599
+ function extractConditionCue(line: string): string | null {
600
+ const inline = line.match(/^if\s*\((.*)\)\s*\{?$/)
601
+ if (inline) {
602
+ return normalizeCue(inline[1] ?? "")
603
+ }
604
+
605
+ const ternary = line.match(/^.*\?.*:.*/)
606
+ if (ternary) {
607
+ return normalizeCue("ternary-condition")
608
+ }
609
+
610
+ return null
611
+ }
612
+
613
+ function extractGuardCue(line: string): string | null {
614
+ if (line.startsWith("return")) {
615
+ return normalizeCue(line)
616
+ }
617
+ if (line.startsWith("throw")) {
618
+ return normalizeCue(line)
619
+ }
620
+ if (line.includes(".preventDefault(")) {
621
+ return "preventDefault()"
622
+ }
623
+ if (line.includes(".stopPropagation(")) {
624
+ return "stopPropagation()"
625
+ }
626
+ return null
627
+ }
628
+
629
+ function normalizeCue(value: string): string {
630
+ const compact = value.replace(/\s+/g, " ").trim()
631
+ if (compact.length <= 72) {
632
+ return compact
633
+ }
634
+ return `${compact.slice(0, 69).trimEnd()}...`
635
+ }
636
+
637
+ function aggregateBehaviorCues(cuesList: BehaviorCues[]): BehaviorCues {
638
+ const addedConditions = new Set<string>()
639
+ const removedConditions = new Set<string>()
640
+ const addedGuards = new Set<string>()
641
+ const removedGuards = new Set<string>()
642
+
643
+ for (const cues of cuesList) {
644
+ for (const value of cues.addedConditions) {
645
+ addedConditions.add(value)
646
+ }
647
+ for (const value of cues.removedConditions) {
648
+ removedConditions.add(value)
649
+ }
650
+ for (const value of cues.addedGuards) {
651
+ addedGuards.add(value)
652
+ }
653
+ for (const value of cues.removedGuards) {
654
+ removedGuards.add(value)
655
+ }
656
+ }
657
+
658
+ return {
659
+ addedConditions: Array.from(addedConditions),
660
+ removedConditions: Array.from(removedConditions),
661
+ addedGuards: Array.from(addedGuards),
662
+ removedGuards: Array.from(removedGuards),
663
+ }
664
+ }
665
+
666
+ function formatCueList(values: string[]): string {
667
+ if (values.length === 0) {
668
+ return "none"
669
+ }
670
+ const limit = values.slice(0, 6)
671
+ return limit.join(" | ")
672
+ }
673
+
674
+ function resolveMaxOutputTokens(reasoningEffort: "low" | "medium" | "high"): number {
675
+ if (reasoningEffort === "high") return 4096
676
+ if (reasoningEffort === "medium") return 3072
677
+ return 2048
678
+ }
679
+
680
+ function extractJsonObject(text: string): unknown {
681
+ const raw = text.trim()
682
+ if (!raw) {
683
+ return null
684
+ }
685
+
686
+ const direct = tryParseJson(raw)
687
+ if (direct !== null) {
688
+ return direct
689
+ }
690
+
691
+ const firstBrace = raw.indexOf("{")
692
+ const lastBrace = raw.lastIndexOf("}")
693
+ if (firstBrace >= 0 && lastBrace > firstBrace) {
694
+ return tryParseJson(raw.slice(firstBrace, lastBrace + 1))
695
+ }
696
+
697
+ return null
698
+ }
699
+
700
+ function tryParseJson(raw: string): unknown {
701
+ try {
702
+ return JSON.parse(raw)
703
+ } catch {
704
+ return null
705
+ }
706
+ }