From b8e5d1285b261ff88b34d0696e2540dcc02776fa Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Tue, 21 Apr 2026 18:57:45 -0300 Subject: [PATCH 01/38] Add PLAN.md for SDK-typed reasoning effort selection across providers --- PLAN.md | 592 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 592 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..c9ec580 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,592 @@ +# Add reasoning "effort" selection across all providers (SDK-first) + +## Context + +`commit-tools` lets users pick a model per provider (Gemini, OpenAI, Anthropic) but always calls the API with default reasoning settings. Modern reasoning models expose a user-tunable "effort" knob. Instead of modelling this ourselves with string enums and prefix allow-lists, **we derive every effort value from the official SDK types** and **defer model-capability detection to runtime** (try the SDK-typed param; on API 400 about `reasoning`/`thinking`, degrade gracefully; cache the outcome on the config so we never ask twice for the same model). + +No regex. No prefix lists. No hand-maintained "does model X support reasoning" map. SDKs are the single source of truth for what values are valid; the API is the single source of truth for whether the chosen model accepts them. + +### What each SDK already gives us (verified by reading the installed `.d.ts`) + +| Provider | SDK (installed) | Effort types exposed | +|---|---|---| +| OpenAI | `openai@6.25.0` — [node_modules/openai/resources/shared.d.ts:143-193](node_modules/openai/resources/shared.d.ts#L143-L193) | `interface Reasoning { effort?: ReasoningEffort \| null; summary?: ... }`
`type ReasoningEffort = 'none' \| 'minimal' \| 'low' \| 'medium' \| 'high' \| 'xhigh' \| null` | +| Anthropic | `@anthropic-ai/sdk@0.87.0` — [node_modules/@anthropic-ai/sdk/resources/messages/messages.d.ts:708-1023](node_modules/@anthropic-ai/sdk/resources/messages/messages.d.ts#L708-L1023) | `interface OutputConfig { effort?: 'low' \| 'medium' \| 'high' \| 'max' \| null; format?: ... }`
`type ThinkingConfigParam = ThinkingConfigEnabled \| ThinkingConfigDisabled \| ThinkingConfigAdaptive`
`interface ThinkingConfigAdaptive { type: 'adaptive'; display?: ... }`
`interface ThinkingConfigEnabled { type: 'enabled'; budget_tokens: number; display?: ... }` | +| Google | **`@google/genai` (to install, replaces deprecated `@google/generative-ai@0.24.1`)** | `interface ThinkingConfig { thinkingBudget?: number; thinkingLevel?: ThinkingLevel; includeThoughts?: boolean; }`
`type ThinkingLevel = 'LOW' \| 'MEDIUM' \| 'HIGH'` (with `MINIMAL` on Gemini 3 Flash) | + +**Critical observation:** none of the three SDKs expose a machine-readable "does model X support reasoning?" predicate. That data lives on the API side. We therefore treat capability as a **runtime property** — try, observe, remember. + +### Decisions confirmed by the user + +1. **Runtime fallback + cache** for capability detection (no hardcoded tables, no regex). +2. **Migrate `@google/generative-ai` → `@google/genai`** bundled in this PR (EOL 2025-08-31 on the old SDK anyway). +3. Storage uses `Maybe` per `CONVENTIONS.md`. Effort types are **per-provider**, each pinned to the SDK's own type unions. +4. Slider UI appears after model selection in both `cli/model.ts` and `cli/setup.ts`. If the cache says "unsupported on this model", the slider is skipped silently. + +--- + +## 0. Dependency change + +- [package.json](package.json) + - Remove: `"@google/generative-ai": "^0.24.1"` (deprecated, EOL 2025-08-31). + - Add: `"@google/genai": "^1.x"` (pin at install time to the latest stable). +- Run `pnpm install` to refresh the lockfile. +- Update all `@google/generative-ai` imports in the repo: currently only [src/infra/llm/gemini.ts:3](src/infra/llm/gemini.ts#L3) (`GoogleGenerativeAI`). Rewrite the api_key branch to use `@google/genai`'s `GoogleGenAI` and `ai.models.generateContent({ model, contents, config: { ... } })` shape (see §6 below). + +## 1. Domain — config schema (per-provider, SDK-typed) + +- [src/domain/config/config.ts](src/domain/config/config.ts) + - Add three effort types, each **pinned to the SDK's own type** with `satisfies` so TypeScript breaks the build if the SDK ever drops a value. + - Add `effort_support` cache field per provider so we never re-probe a known-unsupported model. + +```ts +// src/domain/config/config.ts (additions) +import type OpenAI from "openai"; +import type Anthropic from "@anthropic-ai/sdk"; +import type { ThinkingLevel } from "@google/genai"; + +// --- Value types: taken straight from SDK unions ---------------------------- + +export type OpenAIEffort = NonNullable; +// 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' + +export type AnthropicEffort = NonNullable; +// 'low' | 'medium' | 'high' | 'max' + +export type GeminiEffort = ThinkingLevel; +// 'LOW' | 'MEDIUM' | 'HIGH' (+ MINIMAL on Gemini 3 Flash) + +// --- Runtime value lists (mirror the SDK unions, verified by `satisfies`) --- +// These are the *only* hand-authored string lists in the whole feature, and +// the `satisfies` clauses ensure they stay aligned with the SDK unions. + +const OPENAI_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const satisfies readonly OpenAIEffort[]; +const ANTHROPIC_EFFORTS = ["low", "medium", "high", "max"] as const satisfies readonly AnthropicEffort[]; +const GEMINI_EFFORTS = ["MINIMAL", "LOW", "MEDIUM", "HIGH"] as const satisfies readonly GeminiEffort[]; + +// --- Capability cache ------------------------------------------------------- +// Populated by the runtime fallback chain after the first generate call. + +export type EffortSupport = + | { kind: "unknown" } // model just selected, no probe yet + | { kind: "supported" } // last generate accepted effort + | { kind: "unsupported"; reason: string }; // API rejected effort; remember so we don't ask + +const schema_EffortSupport = s.discriminatedUnion([ + s.variant({ kind: "unknown" as const }), + s.variant({ kind: "supported" as const }), + s.variant({ kind: "unsupported" as const, reason: s.string }) +]); + +// --- ProviderConfig variants: effort is SDK-typed, per provider ------------ + +const schema_ProviderConfig = s.discriminatedUnion([ + s.variant({ + provider: "openai", + model: s.string, + auth_method: schema_AuthMethod, + effort: s.optionalMaybe(s.stringEnum([...OPENAI_EFFORTS])), // Maybe + effort_support: s.optionalMaybe(schema_EffortSupport) // Maybe + }), + s.variant({ + provider: "anthropic", + model: s.string, + auth_method: schema_AuthMethod, + effort: s.optionalMaybe(s.stringEnum([...ANTHROPIC_EFFORTS])), // Maybe + effort_support: s.optionalMaybe(schema_EffortSupport) + }), + s.variant({ + provider: "gemini", + model: s.string, + auth_method: schema_AuthMethod, + effort: s.optionalMaybe(s.stringEnum([...GEMINI_EFFORTS])), // Maybe + effort_support: s.optionalMaybe(schema_EffortSupport) + }) +]); +``` + +**Why `s.optionalMaybe` (not `s.maybe`)** — verified against the library and the project's own precedent: + +| Primitive | Source (schema.ts) | Key required in JSON? | `Nothing` encodes as… | `Just(v)` encodes as… | +|---|---|---|---|---| +| `s.maybe(x)` | [line 158](src/libs/json/schema.ts#L158) | **Yes** (required) | `{ nothing: true }`-ish (via `E.maybe`, per [CONVENTIONS.md:290](CONVENTIONS.md#L290) which explicitly says "**Don't use `E.maybe()` for optional fields — it produces `{ just: V }` structure**") | `{ just: v }` wrapper | +| `s.optionalMaybe(x)` | [line 118](src/libs/json/schema.ts#L118) | **No** (optional) | key **omitted entirely** | raw inner value | + +Three concrete reasons `s.optionalMaybe` wins here: + +1. **Backward compatibility**: existing users' `~/.commit-tools/config.json` has no `effort` / `effort_support` field. With `s.maybe`, the decoder would reject those files on startup (the key is mandatory). With `s.optionalMaybe`, missing → `Nothing()`. +2. **Clean stored JSON**: `s.maybe` produces `"effort": { "just": "high" }` in the on-disk config, which is noisy; `s.optionalMaybe` produces `"effort": "high"` (or omits when `Nothing`). +3. **Existing project precedent**: this exact file already uses [`custom_template: s.optionalMaybe(s.string)`](src/domain/config/config.ts#L85) for the same semantic ("optional user-set value"). Grep confirms **zero uses of `s.maybe` in `src/`** — it's the right primitive for mandatory `Maybe` fields deep inside data structures, not for top-level config flags. + +`s.maybe` would be the right tool if we were, for example, modelling a discriminated-union variant's `result: Maybe` field where every record-of-that-type must carry the field. That's not our shape. + +Both consumers still see the exact same TypeScript type (`Maybe`) — the only difference is on-disk format and back-compat. + +## 2. Domain — effort translators (new file) + +- [src/domain/llm/effort.ts](src/domain/llm/effort.ts) — **no capability detection**. Just three tiny functions that wrap the user's SDK-typed value into the correct SDK-typed request param, plus a small fallback mapper for Anthropic's adaptive→enabled degrade and Gemini's level→budget degrade. + +```ts +// src/domain/llm/effort.ts +export { + openaiReasoningParam, + anthropicAdaptiveParam, + anthropicEnabledParam, + geminiLevelConfig, + geminiBudgetConfig +}; + +import type OpenAI from "openai"; +import type Anthropic from "@anthropic-ai/sdk"; +import type { ThinkingConfig } from "@google/genai"; +import type { OpenAIEffort, AnthropicEffort, GeminiEffort } from "@/domain/config/config"; +import { type Maybe } from "@/libs/maybe"; + +// OpenAI: trivial pass-through — the SDK type IS our storage type. +const openaiReasoningParam = (effort: Maybe): Pick | undefined => + effort.maybe(undefined, (e) => ({ reasoning: { effort: e } satisfies OpenAI.Reasoning })); + +// Anthropic — preferred (adaptive) path. Pure wrap in SDK types. +const anthropicAdaptiveParam = (effort: Maybe): + Pick | undefined => + effort.maybe(undefined, (e) => ({ + thinking: { type: "adaptive" } satisfies Anthropic.ThinkingConfigAdaptive, + output_config: { effort: e } satisfies Anthropic.OutputConfig + })); + +// Anthropic — fallback (enabled + budget_tokens) path. Used only when the API +// rejects the adaptive shape (older Claude models, or Claude Code backend). +// The effort→tokens mapping is documented as a graceful-degradation fallback, +// NOT as capability detection — the user's semantic choice is preserved. +const BUDGET_BY_EFFORT: Record = { + low: 1024, medium: 4096, high: 16384, max: 24576 +}; +const anthropicEnabledParam = (effort: Maybe, baseMaxTokens: number): + { thinking: Anthropic.ThinkingConfigEnabled; max_tokens: number } | undefined => + effort.maybe(undefined, (e) => { + const budget = BUDGET_BY_EFFORT[e]; + return { + thinking: { type: "enabled", budget_tokens: budget } satisfies Anthropic.ThinkingConfigEnabled, + max_tokens: Math.max(baseMaxTokens, budget + 1024) // SDK requires max_tokens > budget_tokens + }; + }); + +// Gemini — preferred (thinkingLevel) path for Gemini 3. +const geminiLevelConfig = (effort: Maybe): { thinkingConfig: ThinkingConfig } | undefined => + effort.maybe(undefined, (e) => ({ thinkingConfig: { thinkingLevel: e } })); + +// Gemini — fallback (thinkingBudget) path for Gemini 2.5. +const BUDGET_BY_LEVEL: Record = { + MINIMAL: 128, LOW: 512, MEDIUM: 2048, HIGH: 8192 +}; +const geminiBudgetConfig = (effort: Maybe): { thinkingConfig: ThinkingConfig } | undefined => + effort.maybe(undefined, (e) => ({ thinkingConfig: { thinkingBudget: BUDGET_BY_LEVEL[e] } })); +``` + +Every return type (`OpenAI.Reasoning`, `Anthropic.ThinkingConfigAdaptive`, `Anthropic.ThinkingConfigEnabled`, `@google/genai`'s `ThinkingConfig`) is directly an SDK type. The `satisfies` keyword ensures any SDK change breaks the build. + +## 3. Infra — runtime fallback chain + +- [src/infra/llm/effort-fallback.ts](src/infra/llm/effort-fallback.ts) — new module. Provides a generic "try with effort, on specific 400 strip/downgrade, on success mark supported, on failure mark unsupported with reason" wrapper around each provider's send. + +```ts +// src/infra/llm/effort-fallback.ts +export { tryWithEffort, type EffortAttempt, type EffortResult }; + +import { Future } from "@/libs/future"; +import type { EffortSupport } from "@/domain/config/config"; + +// An EffortAttempt is a function that runs ONE request variant. Provider +// adapters supply an ordered list: richest shape first, empty shape last. +type EffortAttempt = () => Future; + +type EffortResult = { value: T; support: EffortSupport }; + +// Predicate: is this error the API telling us the effort shape is unsupported +// for this model? Matches known signals without hardcoding model IDs. +const isEffortRejection = (err: Error): boolean => { + const msg = err.message; + return /reasoning|thinking|thinking_config|output_config|budget_tokens/i.test(msg) + && /(400|invalid_request|unsupported_parameter|bad_request)/i.test(msg); +}; + +// Try attempts in order. The LAST attempt (stripped) is always run on failure +// so the user's generate never hard-fails just because effort was unsupported. +const tryWithEffort = (attempts: [EffortAttempt, ...EffortAttempt[]]): Future> => { + const [first, ...rest] = attempts; + + const walk = (n: number, fn: EffortAttempt, remaining: EffortAttempt[]): Future> => + fn().map>((value) => ({ + value, + support: n === 0 ? { kind: "supported" } : { kind: "unsupported", reason: `Degraded at attempt ${n}` } + })).chainRej((err) => { + if (!isEffortRejection(err) || remaining.length === 0) return Future.reject(err); + const [next, ...tail] = remaining; + return walk(n + 1, next, tail); + }); + + return walk(0, first, rest); +}; +``` + +The returned `EffortResult` carries the new `EffortSupport` value so the caller can persist it to config. + +## 4. Infra — UI effort slider (Ink, horizontal, colored) + +Layout and controls per the screenshots you provided (no separate "selected indicator" row — selection = colored bold label + ▲ on the rail directly above it). + +### 4a. Slider component + +- [src/infra/ui/effort-slider.tsx](src/infra/ui/effort-slider.tsx) — new Ink component, same style as [src/infra/ui/model-selector.tsx](src/infra/ui/model-selector.tsx). Split handlers to respect the `sonarjs/cognitive-complexity: 10` threshold from [eslint.config.js](eslint.config.js). + +```tsx +// src/infra/ui/effort-slider.tsx (key shape) +export { EffortSlider, type EffortSliderProps }; + +import * as React from "react"; +import { Box, Text, useInput, useApp, type Key } from "ink"; +import chalk from "chalk"; + +type EffortSliderProps = { + title: string; + options: readonly string[]; // SDK-typed string values, rendered in order + initialIndex: number; + onSubmit: (value: string) => void; + onCancel: () => void; +}; + +const PALETTE = { + 6: ["yellow", "green", "cyan", "blueBright", "magenta", "red"], + 5: ["yellow", "green", "cyan", "magenta", "red"], + 4: ["yellow", "green", "magenta", "red"], + 3: ["yellow", "green", "red"], + 2: ["yellow", "red"] +} as const; + +const paletteFor = (n: number): readonly string[] => + (PALETTE as Record)[n] ?? PALETTE[6]; + +const EffortSlider = ({ title, options, initialIndex, onSubmit, onCancel }: EffortSliderProps) => { + const { exit } = useApp(); + const [index, setIndex] = React.useState(Math.max(0, Math.min(options.length - 1, initialIndex))); + + const handleLifecycle = (key: Key): boolean => { + if (key.escape) { onCancel(); exit(); return true; } + if (key.return) { onSubmit(options[index]); exit(); return true; } + return false; + }; + const handleNavigation = (key: Key): boolean => { + if (key.leftArrow) { setIndex((i) => Math.max(0, i - 1)); return true; } + if (key.rightArrow) { setIndex((i) => Math.min(options.length - 1, i + 1)); return true; } + return false; + }; + useInput((_input, key) => { if (handleLifecycle(key)) return; handleNavigation(key); }); + + const cols = Math.min(72, Math.max(40, (process.stdout.columns ?? 72) - 4)); + const palette = paletteFor(options.length); + const step = options.length > 1 ? Math.floor(cols / (options.length - 1)) : 0; + const markerCol = index * step; + + const rail = Array.from({ length: cols }) + .map((_, c) => (c === markerCol ? (chalk as Record string>)[palette[index]]!("▲") : chalk.dim("─"))) + .join(""); + + const labels = options + .map((o, i) => { + const color = palette[i] ?? "gray"; + const fn = (chalk as Record string } & ((s: string) => string)>)[color]!; + return i === index ? fn.bold(o) : chalk.gray(o); + }) + .join(" "); + + return ( + + {title} + + + + Speed + {" ".repeat(Math.max(1, cols - "Speed".length - "Intelligence".length))} + Intelligence + + {rail} + {labels} + + Use ◀ ▶ to adjust • Enter to confirm • Esc to cancel + + ); +}; +``` + +### 4b. Picker wrapper + +- [src/infra/ui/effort-picker.ts](src/infra/ui/effort-picker.ts) — Future/dynamic-import wrapper. Dispatches to the provider's SDK-typed option list. Does **not** check capability itself — the only thing that skips the prompt is a cached `effort_support.kind === "unsupported"` on the config. + +```ts +// src/infra/ui/effort-picker.ts +export { selectEffortInteractively }; + +import { Future } from "@/libs/future"; +import { Nothing, type Maybe } from "@/libs/maybe"; +import type { ProviderConfig } from "@/domain/config/config"; + +// SDK-driven option arrays (from config.ts, verified by `satisfies` against +// the SDK type unions). +const OPTIONS_BY_PROVIDER = { + openai: ["none", "minimal", "low", "medium", "high", "xhigh"] as const, + anthropic: ["low", "medium", "high", "max"] as const, + gemini: ["MINIMAL", "LOW", "MEDIUM", "HIGH"] as const +} as const; + +const DEFAULT_INDEX = { openai: 3, anthropic: 2, gemini: 3 } as const; + +const selectEffortInteractively = (config: ProviderConfig): Future> => { + // Cached "unsupported" → skip silently. + if (config.effort_support.maybe(false, (s) => s.kind === "unsupported")) { + return Future.resolve(Nothing()); + } + + const options = OPTIONS_BY_PROVIDER[config.provider]; + const current = config.effort.maybe(null, (v) => v); + const initialIndex = current === null ? DEFAULT_INDEX[config.provider] : options.indexOf(current as never); + + return Future.attemptP(async () => { + const { render } = await import("ink"); + const React = await import("react"); + const { EffortSlider } = await import("@/infra/ui/effort-slider"); + const { Just, Nothing } = await import("@/libs/maybe"); + + return new Promise>((resolve, reject) => { + const { unmount } = render( + React.createElement(EffortSlider, { + title: `Reasoning effort for ${config.model}`, + options, + initialIndex: Math.max(0, initialIndex), + onSubmit: (v) => { unmount(); resolve(Just(v)); }, + onCancel: () => { unmount(); reject(new Error("Selection cancelled")); } + }) + ); + }); + }); +}; +``` + +### 4c. UI mockups (per provider) + +**OpenAI (`gpt-5-mini`, 6 options — the full `OpenAI.ReasoningEffort` enum):** +``` +◆ Reasoning effort for gpt-5-mini +│ +│ Speed Intelligence +│ ──────────────────────────────▲──────────────────────────────────── +│ none minimal low medium high xhigh +│ +│ Use ◀ ▶ to adjust • Enter to confirm • Esc to cancel +``` +(`none` yellow, `minimal` green, `low` cyan, `medium` blueBright [selected/bold], `high` magenta, `xhigh` red.) + +**Anthropic (`claude-opus-4-6`, 4 options from `Anthropic.OutputConfig.effort`):** +``` +◆ Reasoning effort for claude-opus-4-6 +│ +│ Speed Intelligence +│ ──────────────────────────────────────────▲────────────────────── +│ low medium high max +``` +(`low` yellow, `medium` green, `high` magenta [selected/bold], `max` red. Same UI on Claude 3.7 / 4 / 4.5 — we preserve the user's semantic choice and let runtime fallback convert to `budget_tokens` if the API rejects adaptive.) + +**Gemini (`gemini-2.5-pro` OR `gemini-3-pro-preview`, 4 options from `@google/genai`'s `ThinkingLevel`):** +``` +◆ Reasoning effort for gemini-3-pro-preview +│ +│ Speed Intelligence +│ ────────────────────────────────────────────────────────▲──────── +│ MINIMAL LOW MEDIUM HIGH +``` +(Gemini 2.5 Pro receives the same UI — user picks a level; runtime fallback maps to `thinkingBudget` if `thinkingLevel` is rejected on 2.5.) + +**After a successful generate marks the model as `unsupported`** (runtime says no): next `commit-tools model` invocation silently skips the slider. Manual override available via a follow-up `commit-tools effort --reset` (out of scope for this PR). + +## 5. CLI — model & setup commands + +- [src/cli/model.ts](src/cli/model.ts) — add effort selection right after model picker, then persist both fields: + +```ts +// src/cli/model.ts (run method) +run(): Future { + p.intro(color.bgCyan(color.black(" Change Model "))); + + return loading("Fetching available models...", "Models fetched!", + fetchModels(this.providerConfig.provider, this.providerConfig.auth_method)) + .chain((models) => selectModelInteractively(models)) + .chain((modelId) => + // Model changed → reset effort_support to "unknown"; we'll re-learn on next generate. + selectEffortInteractively({ + ...this.config.ai, + model: modelId, + effort_support: Just({ kind: "unknown" as const }) + }) + .map((effort) => ({ modelId, effort })) + ) + .chain(({ modelId, effort }) => + saveConfig({ + ...this.config, + ai: withModelAndEffort(this.config.ai, modelId, effort) + }) + ) + .map(() => { p.outro(color.green("Model updated successfully!")); }) + .mapRej((e) => { p.log.error(color.red(e.message)); return e; }); +} +``` + +`withModelAndEffort` is a tiny exhaustive helper next to `run`: +```ts +const withModelAndEffort = ( + current: ProviderConfig, modelId: string, effort: Maybe +): ProviderConfig => { + const reset = Just({ kind: "unknown" as const }); + switch (current.provider) { + case "openai": return { ...current, model: modelId, effort: effort as Maybe, effort_support: reset }; + case "anthropic": return { ...current, model: modelId, effort: effort as Maybe, effort_support: reset }; + case "gemini": return { ...current, model: modelId, effort: effort as Maybe, effort_support: reset }; + default: return absurd(current, "ProviderConfig"); + } +}; +``` + +- [src/cli/setup.ts](src/cli/setup.ts) — same insertion: after model selection (~line 163), before save. + +## 6. Infra — provider adapters (SDK-typed, runtime-fallback) + +### 6a. OpenAI — [src/infra/llm/openai.ts](src/infra/llm/openai.ts) + +```ts +// Chain: with reasoning → without reasoning (if API rejects). +const runWithOpenAI = ( + mk: (withReasoning: boolean) => Future, + effort: Maybe +): Future> => + tryWithEffort( + effort.maybe<[EffortAttempt, ...EffortAttempt[]]>( + [() => mk(false)], // no effort stored → just run + () => [() => mk(true), () => mk(false)] // try with, then without + ) + ); + +// api_key branch +const callOpenAIWithApiKey = (authToken: string, model: string, effort: Maybe, params: GenerateContentParams) => { + const mk = (withReasoning: boolean) => Future.attemptP(async () => { + const client = new OpenAI({ apiKey: authToken }); + const base: OpenAI.Responses.ResponseCreateParams = { + model, + instructions: params.systemInstruction ?? null, + input: params.prompt + }; + const paramsOut = withReasoning ? { ...base, ...openaiReasoningParam(effort) } : base; + return await client.responses.create(paramsOut); + }).mapRej(toError).chain((r) => extractResponse({ provider: "openai", source: "direct", value: r })); + return runWithOpenAI(mk, effort); +}; + +// Codex-OAuth branch has the same pattern; the same tryWithEffort handles its 4xx fallback. +``` + +### 6b. Anthropic — [src/infra/llm/anthropic.ts](src/infra/llm/anthropic.ts) + +Three-step fallback: `adaptive` → `enabled`+`budget_tokens` → no thinking. Uses the SDK-typed variants we defined in §2. + +```ts +const callAnthropicWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams) => { + const mk = (stage: "adaptive" | "enabled" | "off") => Future.attemptP(async () => { + const client = new Anthropic({ apiKey }); + const base: Anthropic.MessageCreateParams = { + model, max_tokens: 4096, + ...(params.systemInstruction !== undefined ? { system: params.systemInstruction } : {}), + messages: [{ role: "user", content: params.prompt }] + }; + const paramsOut: Anthropic.MessageCreateParams = + stage === "adaptive" ? { ...base, ...anthropicAdaptiveParam(effort) } + : stage === "enabled" ? + (() => { + const ep = anthropicEnabledParam(effort, 4096); + return ep ? { ...base, thinking: ep.thinking, max_tokens: ep.max_tokens } : base; + })() + : base; + return await client.messages.create(paramsOut); + }).mapRej(toError).chain((r) => extractResponse({ provider: "anthropic", value: r })); + + return tryWithEffort( + effort.maybe<[EffortAttempt, ...EffortAttempt[]]>( + [() => mk("off")], + () => [() => mk("adaptive"), () => mk("enabled"), () => mk("off")] + ) + ); +}; +``` + +The Claude Code OAuth path (`anthropic_setup_token`) uses the **same** fallback chain — if the backend rejects adaptive because the beta header isn't enabled, it falls through to `enabled` cleanly. No special-casing of auth_method. + +### 6c. Gemini — [src/infra/llm/gemini.ts](src/infra/llm/gemini.ts) + +**Rewritten to use `@google/genai`** (the deprecated SDK is swapped). Fallback chain: `thinkingLevel` (Gemini 3 style) → `thinkingBudget` (Gemini 2.5 style) → no thinking. + +```ts +// api_key branch — rewritten to @google/genai +import { GoogleGenAI, type ThinkingConfig } from "@google/genai"; + +const callGeminiWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams) => { + const mk = (stage: "level" | "budget" | "off") => Future.attemptP(async () => { + const ai = new GoogleGenAI({ apiKey }); + const config: Record = {}; + if (params.systemInstruction !== undefined) config.systemInstruction = params.systemInstruction; + if (stage === "level") Object.assign(config, geminiLevelConfig(effort)); + if (stage === "budget") Object.assign(config, geminiBudgetConfig(effort)); + return await ai.models.generateContent({ + model, + contents: params.prompt, + config + }); + }).mapRej(toError).chain((r) => extractResponse({ provider: "gemini", source: "sdk", value: r })); + + return tryWithEffort( + effort.maybe<[EffortAttempt, ...EffortAttempt[]]>( + [() => mk("off")], + () => [() => mk("level"), () => mk("budget"), () => mk("off")] + ) + ); +}; + +// OAuth REST branch: same fallback, just builds `generationConfig.thinkingConfig` into the JSON body. +``` + +### 6d. Persisting the capability result + +Each adapter returns `Future>` instead of `Future`. [src/domain/llm/router.ts](src/domain/llm/router.ts) catches the result, hands the string back to [src/domain/commit/commit.ts](src/domain/commit/commit.ts) for the user-facing output, and writes the new `effort_support` value back into config via `saveConfig`. Exactly one `saveConfig` call per generate; it's a cheap JSON write. + +## 7. Verification + +1. **Build + lint + typecheck**: `pnpm run build && pnpm run typecheck && pnpm run lint:ci` — all clean. The `satisfies readonly OpenAIEffort[]` clauses must compile against the installed SDK's types. +2. **Schema back-compat**: existing `~/.commit-tools/config.json` (no `effort`/`effort_support`) loads; the `optionalMaybe` decoder yields `Nothing()`; generate still works with zero behaviour change. +3. **Slider values match SDK**: + - `model` on OpenAI shows **exactly** `none / minimal / low / medium / high / xhigh`. + - On Anthropic shows **exactly** `low / medium / high / max`. + - On Gemini shows **exactly** `MINIMAL / LOW / MEDIUM / HIGH`. +4. **Runtime fallback (per provider)** — run one real commit generation against each matrix row and inspect the effective request payload + the persisted `effort_support`: + - OpenAI api_key + `gpt-5-mini` + effort=`high` → request has `reasoning: { effort: "high" }`, config shows `supported`. + - OpenAI api_key + `gpt-4o` + effort=`high` → request fails on 400, retries without `reasoning`, commit still generated, config shows `unsupported` with reason; next `commit-tools model` on same model skips slider. + - Anthropic api_key + `claude-opus-4-6` + effort=`max` → request has `thinking: { type: "adaptive" }` + `output_config: { effort: "max" }`, config `supported`. + - Anthropic api_key + `claude-sonnet-4-5` + effort=`max` → adaptive fails → retries with `enabled + budget_tokens: 24576`, succeeds, config `supported` (different shape, same semantic). + - Anthropic setup_token + `claude-opus-4-6` + effort=`max` → adaptive may fail (no beta header) → falls through to `enabled`, commits succeed. + - Gemini api_key + `gemini-3-pro-preview` + effort=`HIGH` → request has `thinkingConfig.thinkingLevel: "HIGH"`, `supported`. + - Gemini api_key + `gemini-2.5-pro` + effort=`HIGH` → level rejected → retries with `thinkingBudget: 8192`, succeeds, `supported`. + - Gemini OAuth + `gemini-1.5-pro-latest` + any effort → both level and budget rejected → retries without thinking, succeeds, `unsupported`. +5. **Persistence**: confirm `~/.commit-tools/config.json` now contains `effort: { just: "high" }` and `effort_support: { just: { kind: "supported" } }` (or equivalent encoded form) after each scenario. +6. **Doctor**: `commit-tools doctor` prints the effort field cleanly. + +## 8. Out of scope + +- A dedicated `commit-tools effort` / `commit-tools effort --reset` subcommand (trivial follow-up once this lands; re-running `commit-tools model` already refreshes both fields). +- Anthropic `thinking.display: "summarized" | "omitted"` control — not exposed; we pass the SDK's default. +- Gemini `includeThoughts` toggle — we never surface thinking tokens to the user in commit output. +- Exact budget integers for power users (Anthropic `budget_tokens` / Gemini `thinkingBudget`) — we only map semantic tiers; a separate advanced prompt could add it later. +- Migrating the OAuth REST Gemini path entirely to `@google/genai` (the SDK exposes an OAuth-token client too, but the existing REST call keeps the diff smaller; a follow-up can consolidate). From 2690590ce764f696bc7fedaa7234bada08b261b9 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Tue, 21 Apr 2026 21:06:10 -0300 Subject: [PATCH 02/38] Update AI SDK dependencies to pinned versions - Upgrade `@anthropic-ai/sdk` from ^0.87.0 to 0.90.0. - Replace `@google/generative-ai` with `@google/genai` 1.50.1. - Upgrade `openai` from ^6.25.0 to 6.34.0. - Pin all three AI SDK versions instead of using caret ranges. - Add transitive dependencies including `protobufjs`, `p-retry`, and `google-auth-library`. --- package.json | 6 +- pnpm-lock.yaml | 160 +++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 146 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index 7ed488e..97c548e 100644 --- a/package.json +++ b/package.json @@ -45,9 +45,9 @@ "typescript": "^5" }, "dependencies": { - "@anthropic-ai/sdk": "^0.87.0", + "@anthropic-ai/sdk": "0.90.0", "@clack/prompts": "^1.0.1", - "@google/generative-ai": "^0.24.1", + "@google/genai": "1.50.1", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "fluture": "^14.0.0", @@ -55,7 +55,7 @@ "ink": "^6.8.0", "luxon": "^3.7.2", "open": "^11.0.0", - "openai": "^6.25.0", + "openai": "6.34.0", "picocolors": "^1.1.1", "react": "^19.2.4" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 925b90d..fd52b3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,14 +8,14 @@ importers: .: dependencies: "@anthropic-ai/sdk": - specifier: ^0.87.0 - version: 0.87.0 + specifier: 0.90.0 + version: 0.90.0 "@clack/prompts": specifier: ^1.0.1 version: 1.1.0 - "@google/generative-ai": - specifier: ^0.24.1 - version: 0.24.1 + "@google/genai": + specifier: 1.50.1 + version: 1.50.1 chalk: specifier: ^5.6.2 version: 5.6.2 @@ -38,8 +38,8 @@ importers: specifier: ^11.0.0 version: 11.0.0 openai: - specifier: ^6.25.0 - version: 6.29.0(ws@8.19.0) + specifier: 6.34.0 + version: 6.34.0(ws@8.19.0) picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -96,9 +96,9 @@ packages: { integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw== } engines: { node: ">=18" } - "@anthropic-ai/sdk@0.87.0": + "@anthropic-ai/sdk@0.90.0": resolution: - { integrity: sha512-ZvBWT5VkPTW6b8LIpugpuAkpcYPSLOXdWTcgQrpUqf4IeJ5ZrH5rT8sTsUDvxPCHAlRG3nF4VIWfjw6uLhJ18g== } + { integrity: sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg== } hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -353,10 +353,15 @@ packages: { integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - "@google/generative-ai@0.24.1": + "@google/genai@1.50.1": resolution: - { integrity: sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q== } - engines: { node: ">=18.0.0" } + { integrity: sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ== } + engines: { node: ">=20.0.0" } + peerDependencies: + "@modelcontextprotocol/sdk": ^1.25.2 + peerDependenciesMeta: + "@modelcontextprotocol/sdk": + optional: true "@humanfs/core@0.19.2": resolution: @@ -400,6 +405,46 @@ packages: resolution: { integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== } + "@protobufjs/aspromise@1.1.2": + resolution: + { integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== } + + "@protobufjs/base64@1.1.2": + resolution: + { integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== } + + "@protobufjs/codegen@2.0.4": + resolution: + { integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== } + + "@protobufjs/eventemitter@1.1.0": + resolution: + { integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== } + + "@protobufjs/fetch@1.1.0": + resolution: + { integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== } + + "@protobufjs/float@1.0.2": + resolution: + { integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== } + + "@protobufjs/inquire@1.1.0": + resolution: + { integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== } + + "@protobufjs/path@1.1.2": + resolution: + { integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== } + + "@protobufjs/pool@1.1.0": + resolution: + { integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== } + + "@protobufjs/utf8@1.1.0": + resolution: + { integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== } + "@rollup/rollup-android-arm-eabi@4.59.0": resolution: { integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg== } @@ -623,6 +668,10 @@ packages: resolution: { integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w== } + "@types/retry@0.12.0": + resolution: + { integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== } + "@types/send@1.2.1": resolution: { integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ== } @@ -1330,6 +1379,10 @@ packages: resolution: { integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== } + long@5.3.2: + resolution: + { integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== } + luxon@3.7.2: resolution: { integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew== } @@ -1395,9 +1448,9 @@ packages: { integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw== } engines: { node: ">=20" } - openai@6.29.0: + openai@6.34.0: resolution: - { integrity: sha512-YxoArl2BItucdO89/sN6edksV0x47WUTgkgVfCgX7EuEMhbirENsgYe5oO4LTjBL9PtdKtk2WqND1gSLcTd2yw== } + { integrity: sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw== } hasBin: true peerDependencies: ws: ^8.18.0 @@ -1423,6 +1476,11 @@ packages: { integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== } engines: { node: ">=10" } + p-retry@4.6.2: + resolution: + { integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== } + engines: { node: ">=8" } + parent-module@1.0.1: resolution: { integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== } @@ -1556,6 +1614,11 @@ packages: engines: { node: ">=14" } hasBin: true + protobufjs@7.5.5: + resolution: + { integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg== } + engines: { node: ">=12.0.0" } + punycode@2.3.1: resolution: { integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== } @@ -1611,6 +1674,11 @@ packages: { integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg== } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + retry@0.13.1: + resolution: + { integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== } + engines: { node: ">= 4" } + rollup@4.59.0: resolution: { integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg== } @@ -1910,7 +1978,7 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - "@anthropic-ai/sdk@0.87.0": + "@anthropic-ai/sdk@0.90.0": dependencies: json-schema-to-ts: 3.1.1 @@ -2052,7 +2120,16 @@ snapshots: "@eslint/core": 0.17.0 levn: 0.4.1 - "@google/generative-ai@0.24.1": {} + "@google/genai@1.50.1": + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.5 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate "@humanfs/core@0.19.2": dependencies: @@ -2084,6 +2161,29 @@ snapshots: "@jridgewell/resolve-uri": 3.1.2 "@jridgewell/sourcemap-codec": 1.5.5 + "@protobufjs/aspromise@1.1.2": {} + + "@protobufjs/base64@1.1.2": {} + + "@protobufjs/codegen@2.0.4": {} + + "@protobufjs/eventemitter@1.1.0": {} + + "@protobufjs/fetch@1.1.0": + dependencies: + "@protobufjs/aspromise": 1.1.2 + "@protobufjs/inquire": 1.1.0 + + "@protobufjs/float@1.0.2": {} + + "@protobufjs/inquire@1.1.0": {} + + "@protobufjs/path@1.1.2": {} + + "@protobufjs/pool@1.1.0": {} + + "@protobufjs/utf8@1.1.0": {} + "@rollup/rollup-android-arm-eabi@4.59.0": optional: true @@ -2212,6 +2312,8 @@ snapshots: dependencies: csstype: 3.2.3 + "@types/retry@0.12.0": {} + "@types/send@1.2.1": dependencies: "@types/node": 22.19.15 @@ -2821,6 +2923,8 @@ snapshots: lodash.merge@4.6.2: {} + long@5.3.2: {} + luxon@3.7.2: {} magic-string@0.30.21: @@ -2877,7 +2981,7 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 - openai@6.29.0(ws@8.19.0): + openai@6.34.0(ws@8.19.0): optionalDependencies: ws: 8.19.0 @@ -2898,6 +3002,11 @@ snapshots: dependencies: p-limit: 3.1.0 + p-retry@4.6.2: + dependencies: + "@types/retry": 0.12.0 + retry: 0.13.1 + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -2938,6 +3047,21 @@ snapshots: prettier@3.8.1: {} + protobufjs@7.5.5: + dependencies: + "@protobufjs/aspromise": 1.1.2 + "@protobufjs/base64": 1.1.2 + "@protobufjs/codegen": 2.0.4 + "@protobufjs/eventemitter": 1.1.0 + "@protobufjs/fetch": 1.1.0 + "@protobufjs/float": 1.0.2 + "@protobufjs/inquire": 1.1.0 + "@protobufjs/path": 1.1.2 + "@protobufjs/pool": 1.1.0 + "@protobufjs/utf8": 1.1.0 + "@types/node": 22.19.15 + long: 5.3.2 + punycode@2.3.1: {} react-devtools-core@7.0.1: @@ -2977,6 +3101,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + retry@0.13.1: {} + rollup@4.59.0: dependencies: "@types/estree": 1.0.8 From ed30f26005e285cb67f471ed0f0cc4f452af291c Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Wed, 22 Apr 2026 09:33:02 -0300 Subject: [PATCH 03/38] Remove PLAN.md implementation specification document --- PLAN.md | 592 -------------------------------------------------------- 1 file changed, 592 deletions(-) delete mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index c9ec580..0000000 --- a/PLAN.md +++ /dev/null @@ -1,592 +0,0 @@ -# Add reasoning "effort" selection across all providers (SDK-first) - -## Context - -`commit-tools` lets users pick a model per provider (Gemini, OpenAI, Anthropic) but always calls the API with default reasoning settings. Modern reasoning models expose a user-tunable "effort" knob. Instead of modelling this ourselves with string enums and prefix allow-lists, **we derive every effort value from the official SDK types** and **defer model-capability detection to runtime** (try the SDK-typed param; on API 400 about `reasoning`/`thinking`, degrade gracefully; cache the outcome on the config so we never ask twice for the same model). - -No regex. No prefix lists. No hand-maintained "does model X support reasoning" map. SDKs are the single source of truth for what values are valid; the API is the single source of truth for whether the chosen model accepts them. - -### What each SDK already gives us (verified by reading the installed `.d.ts`) - -| Provider | SDK (installed) | Effort types exposed | -|---|---|---| -| OpenAI | `openai@6.25.0` — [node_modules/openai/resources/shared.d.ts:143-193](node_modules/openai/resources/shared.d.ts#L143-L193) | `interface Reasoning { effort?: ReasoningEffort \| null; summary?: ... }`
`type ReasoningEffort = 'none' \| 'minimal' \| 'low' \| 'medium' \| 'high' \| 'xhigh' \| null` | -| Anthropic | `@anthropic-ai/sdk@0.87.0` — [node_modules/@anthropic-ai/sdk/resources/messages/messages.d.ts:708-1023](node_modules/@anthropic-ai/sdk/resources/messages/messages.d.ts#L708-L1023) | `interface OutputConfig { effort?: 'low' \| 'medium' \| 'high' \| 'max' \| null; format?: ... }`
`type ThinkingConfigParam = ThinkingConfigEnabled \| ThinkingConfigDisabled \| ThinkingConfigAdaptive`
`interface ThinkingConfigAdaptive { type: 'adaptive'; display?: ... }`
`interface ThinkingConfigEnabled { type: 'enabled'; budget_tokens: number; display?: ... }` | -| Google | **`@google/genai` (to install, replaces deprecated `@google/generative-ai@0.24.1`)** | `interface ThinkingConfig { thinkingBudget?: number; thinkingLevel?: ThinkingLevel; includeThoughts?: boolean; }`
`type ThinkingLevel = 'LOW' \| 'MEDIUM' \| 'HIGH'` (with `MINIMAL` on Gemini 3 Flash) | - -**Critical observation:** none of the three SDKs expose a machine-readable "does model X support reasoning?" predicate. That data lives on the API side. We therefore treat capability as a **runtime property** — try, observe, remember. - -### Decisions confirmed by the user - -1. **Runtime fallback + cache** for capability detection (no hardcoded tables, no regex). -2. **Migrate `@google/generative-ai` → `@google/genai`** bundled in this PR (EOL 2025-08-31 on the old SDK anyway). -3. Storage uses `Maybe` per `CONVENTIONS.md`. Effort types are **per-provider**, each pinned to the SDK's own type unions. -4. Slider UI appears after model selection in both `cli/model.ts` and `cli/setup.ts`. If the cache says "unsupported on this model", the slider is skipped silently. - ---- - -## 0. Dependency change - -- [package.json](package.json) - - Remove: `"@google/generative-ai": "^0.24.1"` (deprecated, EOL 2025-08-31). - - Add: `"@google/genai": "^1.x"` (pin at install time to the latest stable). -- Run `pnpm install` to refresh the lockfile. -- Update all `@google/generative-ai` imports in the repo: currently only [src/infra/llm/gemini.ts:3](src/infra/llm/gemini.ts#L3) (`GoogleGenerativeAI`). Rewrite the api_key branch to use `@google/genai`'s `GoogleGenAI` and `ai.models.generateContent({ model, contents, config: { ... } })` shape (see §6 below). - -## 1. Domain — config schema (per-provider, SDK-typed) - -- [src/domain/config/config.ts](src/domain/config/config.ts) - - Add three effort types, each **pinned to the SDK's own type** with `satisfies` so TypeScript breaks the build if the SDK ever drops a value. - - Add `effort_support` cache field per provider so we never re-probe a known-unsupported model. - -```ts -// src/domain/config/config.ts (additions) -import type OpenAI from "openai"; -import type Anthropic from "@anthropic-ai/sdk"; -import type { ThinkingLevel } from "@google/genai"; - -// --- Value types: taken straight from SDK unions ---------------------------- - -export type OpenAIEffort = NonNullable; -// 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' - -export type AnthropicEffort = NonNullable; -// 'low' | 'medium' | 'high' | 'max' - -export type GeminiEffort = ThinkingLevel; -// 'LOW' | 'MEDIUM' | 'HIGH' (+ MINIMAL on Gemini 3 Flash) - -// --- Runtime value lists (mirror the SDK unions, verified by `satisfies`) --- -// These are the *only* hand-authored string lists in the whole feature, and -// the `satisfies` clauses ensure they stay aligned with the SDK unions. - -const OPENAI_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const satisfies readonly OpenAIEffort[]; -const ANTHROPIC_EFFORTS = ["low", "medium", "high", "max"] as const satisfies readonly AnthropicEffort[]; -const GEMINI_EFFORTS = ["MINIMAL", "LOW", "MEDIUM", "HIGH"] as const satisfies readonly GeminiEffort[]; - -// --- Capability cache ------------------------------------------------------- -// Populated by the runtime fallback chain after the first generate call. - -export type EffortSupport = - | { kind: "unknown" } // model just selected, no probe yet - | { kind: "supported" } // last generate accepted effort - | { kind: "unsupported"; reason: string }; // API rejected effort; remember so we don't ask - -const schema_EffortSupport = s.discriminatedUnion([ - s.variant({ kind: "unknown" as const }), - s.variant({ kind: "supported" as const }), - s.variant({ kind: "unsupported" as const, reason: s.string }) -]); - -// --- ProviderConfig variants: effort is SDK-typed, per provider ------------ - -const schema_ProviderConfig = s.discriminatedUnion([ - s.variant({ - provider: "openai", - model: s.string, - auth_method: schema_AuthMethod, - effort: s.optionalMaybe(s.stringEnum([...OPENAI_EFFORTS])), // Maybe - effort_support: s.optionalMaybe(schema_EffortSupport) // Maybe - }), - s.variant({ - provider: "anthropic", - model: s.string, - auth_method: schema_AuthMethod, - effort: s.optionalMaybe(s.stringEnum([...ANTHROPIC_EFFORTS])), // Maybe - effort_support: s.optionalMaybe(schema_EffortSupport) - }), - s.variant({ - provider: "gemini", - model: s.string, - auth_method: schema_AuthMethod, - effort: s.optionalMaybe(s.stringEnum([...GEMINI_EFFORTS])), // Maybe - effort_support: s.optionalMaybe(schema_EffortSupport) - }) -]); -``` - -**Why `s.optionalMaybe` (not `s.maybe`)** — verified against the library and the project's own precedent: - -| Primitive | Source (schema.ts) | Key required in JSON? | `Nothing` encodes as… | `Just(v)` encodes as… | -|---|---|---|---|---| -| `s.maybe(x)` | [line 158](src/libs/json/schema.ts#L158) | **Yes** (required) | `{ nothing: true }`-ish (via `E.maybe`, per [CONVENTIONS.md:290](CONVENTIONS.md#L290) which explicitly says "**Don't use `E.maybe()` for optional fields — it produces `{ just: V }` structure**") | `{ just: v }` wrapper | -| `s.optionalMaybe(x)` | [line 118](src/libs/json/schema.ts#L118) | **No** (optional) | key **omitted entirely** | raw inner value | - -Three concrete reasons `s.optionalMaybe` wins here: - -1. **Backward compatibility**: existing users' `~/.commit-tools/config.json` has no `effort` / `effort_support` field. With `s.maybe`, the decoder would reject those files on startup (the key is mandatory). With `s.optionalMaybe`, missing → `Nothing()`. -2. **Clean stored JSON**: `s.maybe` produces `"effort": { "just": "high" }` in the on-disk config, which is noisy; `s.optionalMaybe` produces `"effort": "high"` (or omits when `Nothing`). -3. **Existing project precedent**: this exact file already uses [`custom_template: s.optionalMaybe(s.string)`](src/domain/config/config.ts#L85) for the same semantic ("optional user-set value"). Grep confirms **zero uses of `s.maybe` in `src/`** — it's the right primitive for mandatory `Maybe` fields deep inside data structures, not for top-level config flags. - -`s.maybe` would be the right tool if we were, for example, modelling a discriminated-union variant's `result: Maybe` field where every record-of-that-type must carry the field. That's not our shape. - -Both consumers still see the exact same TypeScript type (`Maybe`) — the only difference is on-disk format and back-compat. - -## 2. Domain — effort translators (new file) - -- [src/domain/llm/effort.ts](src/domain/llm/effort.ts) — **no capability detection**. Just three tiny functions that wrap the user's SDK-typed value into the correct SDK-typed request param, plus a small fallback mapper for Anthropic's adaptive→enabled degrade and Gemini's level→budget degrade. - -```ts -// src/domain/llm/effort.ts -export { - openaiReasoningParam, - anthropicAdaptiveParam, - anthropicEnabledParam, - geminiLevelConfig, - geminiBudgetConfig -}; - -import type OpenAI from "openai"; -import type Anthropic from "@anthropic-ai/sdk"; -import type { ThinkingConfig } from "@google/genai"; -import type { OpenAIEffort, AnthropicEffort, GeminiEffort } from "@/domain/config/config"; -import { type Maybe } from "@/libs/maybe"; - -// OpenAI: trivial pass-through — the SDK type IS our storage type. -const openaiReasoningParam = (effort: Maybe): Pick | undefined => - effort.maybe(undefined, (e) => ({ reasoning: { effort: e } satisfies OpenAI.Reasoning })); - -// Anthropic — preferred (adaptive) path. Pure wrap in SDK types. -const anthropicAdaptiveParam = (effort: Maybe): - Pick | undefined => - effort.maybe(undefined, (e) => ({ - thinking: { type: "adaptive" } satisfies Anthropic.ThinkingConfigAdaptive, - output_config: { effort: e } satisfies Anthropic.OutputConfig - })); - -// Anthropic — fallback (enabled + budget_tokens) path. Used only when the API -// rejects the adaptive shape (older Claude models, or Claude Code backend). -// The effort→tokens mapping is documented as a graceful-degradation fallback, -// NOT as capability detection — the user's semantic choice is preserved. -const BUDGET_BY_EFFORT: Record = { - low: 1024, medium: 4096, high: 16384, max: 24576 -}; -const anthropicEnabledParam = (effort: Maybe, baseMaxTokens: number): - { thinking: Anthropic.ThinkingConfigEnabled; max_tokens: number } | undefined => - effort.maybe(undefined, (e) => { - const budget = BUDGET_BY_EFFORT[e]; - return { - thinking: { type: "enabled", budget_tokens: budget } satisfies Anthropic.ThinkingConfigEnabled, - max_tokens: Math.max(baseMaxTokens, budget + 1024) // SDK requires max_tokens > budget_tokens - }; - }); - -// Gemini — preferred (thinkingLevel) path for Gemini 3. -const geminiLevelConfig = (effort: Maybe): { thinkingConfig: ThinkingConfig } | undefined => - effort.maybe(undefined, (e) => ({ thinkingConfig: { thinkingLevel: e } })); - -// Gemini — fallback (thinkingBudget) path for Gemini 2.5. -const BUDGET_BY_LEVEL: Record = { - MINIMAL: 128, LOW: 512, MEDIUM: 2048, HIGH: 8192 -}; -const geminiBudgetConfig = (effort: Maybe): { thinkingConfig: ThinkingConfig } | undefined => - effort.maybe(undefined, (e) => ({ thinkingConfig: { thinkingBudget: BUDGET_BY_LEVEL[e] } })); -``` - -Every return type (`OpenAI.Reasoning`, `Anthropic.ThinkingConfigAdaptive`, `Anthropic.ThinkingConfigEnabled`, `@google/genai`'s `ThinkingConfig`) is directly an SDK type. The `satisfies` keyword ensures any SDK change breaks the build. - -## 3. Infra — runtime fallback chain - -- [src/infra/llm/effort-fallback.ts](src/infra/llm/effort-fallback.ts) — new module. Provides a generic "try with effort, on specific 400 strip/downgrade, on success mark supported, on failure mark unsupported with reason" wrapper around each provider's send. - -```ts -// src/infra/llm/effort-fallback.ts -export { tryWithEffort, type EffortAttempt, type EffortResult }; - -import { Future } from "@/libs/future"; -import type { EffortSupport } from "@/domain/config/config"; - -// An EffortAttempt is a function that runs ONE request variant. Provider -// adapters supply an ordered list: richest shape first, empty shape last. -type EffortAttempt = () => Future; - -type EffortResult = { value: T; support: EffortSupport }; - -// Predicate: is this error the API telling us the effort shape is unsupported -// for this model? Matches known signals without hardcoding model IDs. -const isEffortRejection = (err: Error): boolean => { - const msg = err.message; - return /reasoning|thinking|thinking_config|output_config|budget_tokens/i.test(msg) - && /(400|invalid_request|unsupported_parameter|bad_request)/i.test(msg); -}; - -// Try attempts in order. The LAST attempt (stripped) is always run on failure -// so the user's generate never hard-fails just because effort was unsupported. -const tryWithEffort = (attempts: [EffortAttempt, ...EffortAttempt[]]): Future> => { - const [first, ...rest] = attempts; - - const walk = (n: number, fn: EffortAttempt, remaining: EffortAttempt[]): Future> => - fn().map>((value) => ({ - value, - support: n === 0 ? { kind: "supported" } : { kind: "unsupported", reason: `Degraded at attempt ${n}` } - })).chainRej((err) => { - if (!isEffortRejection(err) || remaining.length === 0) return Future.reject(err); - const [next, ...tail] = remaining; - return walk(n + 1, next, tail); - }); - - return walk(0, first, rest); -}; -``` - -The returned `EffortResult` carries the new `EffortSupport` value so the caller can persist it to config. - -## 4. Infra — UI effort slider (Ink, horizontal, colored) - -Layout and controls per the screenshots you provided (no separate "selected indicator" row — selection = colored bold label + ▲ on the rail directly above it). - -### 4a. Slider component - -- [src/infra/ui/effort-slider.tsx](src/infra/ui/effort-slider.tsx) — new Ink component, same style as [src/infra/ui/model-selector.tsx](src/infra/ui/model-selector.tsx). Split handlers to respect the `sonarjs/cognitive-complexity: 10` threshold from [eslint.config.js](eslint.config.js). - -```tsx -// src/infra/ui/effort-slider.tsx (key shape) -export { EffortSlider, type EffortSliderProps }; - -import * as React from "react"; -import { Box, Text, useInput, useApp, type Key } from "ink"; -import chalk from "chalk"; - -type EffortSliderProps = { - title: string; - options: readonly string[]; // SDK-typed string values, rendered in order - initialIndex: number; - onSubmit: (value: string) => void; - onCancel: () => void; -}; - -const PALETTE = { - 6: ["yellow", "green", "cyan", "blueBright", "magenta", "red"], - 5: ["yellow", "green", "cyan", "magenta", "red"], - 4: ["yellow", "green", "magenta", "red"], - 3: ["yellow", "green", "red"], - 2: ["yellow", "red"] -} as const; - -const paletteFor = (n: number): readonly string[] => - (PALETTE as Record)[n] ?? PALETTE[6]; - -const EffortSlider = ({ title, options, initialIndex, onSubmit, onCancel }: EffortSliderProps) => { - const { exit } = useApp(); - const [index, setIndex] = React.useState(Math.max(0, Math.min(options.length - 1, initialIndex))); - - const handleLifecycle = (key: Key): boolean => { - if (key.escape) { onCancel(); exit(); return true; } - if (key.return) { onSubmit(options[index]); exit(); return true; } - return false; - }; - const handleNavigation = (key: Key): boolean => { - if (key.leftArrow) { setIndex((i) => Math.max(0, i - 1)); return true; } - if (key.rightArrow) { setIndex((i) => Math.min(options.length - 1, i + 1)); return true; } - return false; - }; - useInput((_input, key) => { if (handleLifecycle(key)) return; handleNavigation(key); }); - - const cols = Math.min(72, Math.max(40, (process.stdout.columns ?? 72) - 4)); - const palette = paletteFor(options.length); - const step = options.length > 1 ? Math.floor(cols / (options.length - 1)) : 0; - const markerCol = index * step; - - const rail = Array.from({ length: cols }) - .map((_, c) => (c === markerCol ? (chalk as Record string>)[palette[index]]!("▲") : chalk.dim("─"))) - .join(""); - - const labels = options - .map((o, i) => { - const color = palette[i] ?? "gray"; - const fn = (chalk as Record string } & ((s: string) => string)>)[color]!; - return i === index ? fn.bold(o) : chalk.gray(o); - }) - .join(" "); - - return ( - - {title} - - - - Speed - {" ".repeat(Math.max(1, cols - "Speed".length - "Intelligence".length))} - Intelligence - - {rail} - {labels} - - Use ◀ ▶ to adjust • Enter to confirm • Esc to cancel - - ); -}; -``` - -### 4b. Picker wrapper - -- [src/infra/ui/effort-picker.ts](src/infra/ui/effort-picker.ts) — Future/dynamic-import wrapper. Dispatches to the provider's SDK-typed option list. Does **not** check capability itself — the only thing that skips the prompt is a cached `effort_support.kind === "unsupported"` on the config. - -```ts -// src/infra/ui/effort-picker.ts -export { selectEffortInteractively }; - -import { Future } from "@/libs/future"; -import { Nothing, type Maybe } from "@/libs/maybe"; -import type { ProviderConfig } from "@/domain/config/config"; - -// SDK-driven option arrays (from config.ts, verified by `satisfies` against -// the SDK type unions). -const OPTIONS_BY_PROVIDER = { - openai: ["none", "minimal", "low", "medium", "high", "xhigh"] as const, - anthropic: ["low", "medium", "high", "max"] as const, - gemini: ["MINIMAL", "LOW", "MEDIUM", "HIGH"] as const -} as const; - -const DEFAULT_INDEX = { openai: 3, anthropic: 2, gemini: 3 } as const; - -const selectEffortInteractively = (config: ProviderConfig): Future> => { - // Cached "unsupported" → skip silently. - if (config.effort_support.maybe(false, (s) => s.kind === "unsupported")) { - return Future.resolve(Nothing()); - } - - const options = OPTIONS_BY_PROVIDER[config.provider]; - const current = config.effort.maybe(null, (v) => v); - const initialIndex = current === null ? DEFAULT_INDEX[config.provider] : options.indexOf(current as never); - - return Future.attemptP(async () => { - const { render } = await import("ink"); - const React = await import("react"); - const { EffortSlider } = await import("@/infra/ui/effort-slider"); - const { Just, Nothing } = await import("@/libs/maybe"); - - return new Promise>((resolve, reject) => { - const { unmount } = render( - React.createElement(EffortSlider, { - title: `Reasoning effort for ${config.model}`, - options, - initialIndex: Math.max(0, initialIndex), - onSubmit: (v) => { unmount(); resolve(Just(v)); }, - onCancel: () => { unmount(); reject(new Error("Selection cancelled")); } - }) - ); - }); - }); -}; -``` - -### 4c. UI mockups (per provider) - -**OpenAI (`gpt-5-mini`, 6 options — the full `OpenAI.ReasoningEffort` enum):** -``` -◆ Reasoning effort for gpt-5-mini -│ -│ Speed Intelligence -│ ──────────────────────────────▲──────────────────────────────────── -│ none minimal low medium high xhigh -│ -│ Use ◀ ▶ to adjust • Enter to confirm • Esc to cancel -``` -(`none` yellow, `minimal` green, `low` cyan, `medium` blueBright [selected/bold], `high` magenta, `xhigh` red.) - -**Anthropic (`claude-opus-4-6`, 4 options from `Anthropic.OutputConfig.effort`):** -``` -◆ Reasoning effort for claude-opus-4-6 -│ -│ Speed Intelligence -│ ──────────────────────────────────────────▲────────────────────── -│ low medium high max -``` -(`low` yellow, `medium` green, `high` magenta [selected/bold], `max` red. Same UI on Claude 3.7 / 4 / 4.5 — we preserve the user's semantic choice and let runtime fallback convert to `budget_tokens` if the API rejects adaptive.) - -**Gemini (`gemini-2.5-pro` OR `gemini-3-pro-preview`, 4 options from `@google/genai`'s `ThinkingLevel`):** -``` -◆ Reasoning effort for gemini-3-pro-preview -│ -│ Speed Intelligence -│ ────────────────────────────────────────────────────────▲──────── -│ MINIMAL LOW MEDIUM HIGH -``` -(Gemini 2.5 Pro receives the same UI — user picks a level; runtime fallback maps to `thinkingBudget` if `thinkingLevel` is rejected on 2.5.) - -**After a successful generate marks the model as `unsupported`** (runtime says no): next `commit-tools model` invocation silently skips the slider. Manual override available via a follow-up `commit-tools effort --reset` (out of scope for this PR). - -## 5. CLI — model & setup commands - -- [src/cli/model.ts](src/cli/model.ts) — add effort selection right after model picker, then persist both fields: - -```ts -// src/cli/model.ts (run method) -run(): Future { - p.intro(color.bgCyan(color.black(" Change Model "))); - - return loading("Fetching available models...", "Models fetched!", - fetchModels(this.providerConfig.provider, this.providerConfig.auth_method)) - .chain((models) => selectModelInteractively(models)) - .chain((modelId) => - // Model changed → reset effort_support to "unknown"; we'll re-learn on next generate. - selectEffortInteractively({ - ...this.config.ai, - model: modelId, - effort_support: Just({ kind: "unknown" as const }) - }) - .map((effort) => ({ modelId, effort })) - ) - .chain(({ modelId, effort }) => - saveConfig({ - ...this.config, - ai: withModelAndEffort(this.config.ai, modelId, effort) - }) - ) - .map(() => { p.outro(color.green("Model updated successfully!")); }) - .mapRej((e) => { p.log.error(color.red(e.message)); return e; }); -} -``` - -`withModelAndEffort` is a tiny exhaustive helper next to `run`: -```ts -const withModelAndEffort = ( - current: ProviderConfig, modelId: string, effort: Maybe -): ProviderConfig => { - const reset = Just({ kind: "unknown" as const }); - switch (current.provider) { - case "openai": return { ...current, model: modelId, effort: effort as Maybe, effort_support: reset }; - case "anthropic": return { ...current, model: modelId, effort: effort as Maybe, effort_support: reset }; - case "gemini": return { ...current, model: modelId, effort: effort as Maybe, effort_support: reset }; - default: return absurd(current, "ProviderConfig"); - } -}; -``` - -- [src/cli/setup.ts](src/cli/setup.ts) — same insertion: after model selection (~line 163), before save. - -## 6. Infra — provider adapters (SDK-typed, runtime-fallback) - -### 6a. OpenAI — [src/infra/llm/openai.ts](src/infra/llm/openai.ts) - -```ts -// Chain: with reasoning → without reasoning (if API rejects). -const runWithOpenAI = ( - mk: (withReasoning: boolean) => Future, - effort: Maybe -): Future> => - tryWithEffort( - effort.maybe<[EffortAttempt, ...EffortAttempt[]]>( - [() => mk(false)], // no effort stored → just run - () => [() => mk(true), () => mk(false)] // try with, then without - ) - ); - -// api_key branch -const callOpenAIWithApiKey = (authToken: string, model: string, effort: Maybe, params: GenerateContentParams) => { - const mk = (withReasoning: boolean) => Future.attemptP(async () => { - const client = new OpenAI({ apiKey: authToken }); - const base: OpenAI.Responses.ResponseCreateParams = { - model, - instructions: params.systemInstruction ?? null, - input: params.prompt - }; - const paramsOut = withReasoning ? { ...base, ...openaiReasoningParam(effort) } : base; - return await client.responses.create(paramsOut); - }).mapRej(toError).chain((r) => extractResponse({ provider: "openai", source: "direct", value: r })); - return runWithOpenAI(mk, effort); -}; - -// Codex-OAuth branch has the same pattern; the same tryWithEffort handles its 4xx fallback. -``` - -### 6b. Anthropic — [src/infra/llm/anthropic.ts](src/infra/llm/anthropic.ts) - -Three-step fallback: `adaptive` → `enabled`+`budget_tokens` → no thinking. Uses the SDK-typed variants we defined in §2. - -```ts -const callAnthropicWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams) => { - const mk = (stage: "adaptive" | "enabled" | "off") => Future.attemptP(async () => { - const client = new Anthropic({ apiKey }); - const base: Anthropic.MessageCreateParams = { - model, max_tokens: 4096, - ...(params.systemInstruction !== undefined ? { system: params.systemInstruction } : {}), - messages: [{ role: "user", content: params.prompt }] - }; - const paramsOut: Anthropic.MessageCreateParams = - stage === "adaptive" ? { ...base, ...anthropicAdaptiveParam(effort) } - : stage === "enabled" ? - (() => { - const ep = anthropicEnabledParam(effort, 4096); - return ep ? { ...base, thinking: ep.thinking, max_tokens: ep.max_tokens } : base; - })() - : base; - return await client.messages.create(paramsOut); - }).mapRej(toError).chain((r) => extractResponse({ provider: "anthropic", value: r })); - - return tryWithEffort( - effort.maybe<[EffortAttempt, ...EffortAttempt[]]>( - [() => mk("off")], - () => [() => mk("adaptive"), () => mk("enabled"), () => mk("off")] - ) - ); -}; -``` - -The Claude Code OAuth path (`anthropic_setup_token`) uses the **same** fallback chain — if the backend rejects adaptive because the beta header isn't enabled, it falls through to `enabled` cleanly. No special-casing of auth_method. - -### 6c. Gemini — [src/infra/llm/gemini.ts](src/infra/llm/gemini.ts) - -**Rewritten to use `@google/genai`** (the deprecated SDK is swapped). Fallback chain: `thinkingLevel` (Gemini 3 style) → `thinkingBudget` (Gemini 2.5 style) → no thinking. - -```ts -// api_key branch — rewritten to @google/genai -import { GoogleGenAI, type ThinkingConfig } from "@google/genai"; - -const callGeminiWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams) => { - const mk = (stage: "level" | "budget" | "off") => Future.attemptP(async () => { - const ai = new GoogleGenAI({ apiKey }); - const config: Record = {}; - if (params.systemInstruction !== undefined) config.systemInstruction = params.systemInstruction; - if (stage === "level") Object.assign(config, geminiLevelConfig(effort)); - if (stage === "budget") Object.assign(config, geminiBudgetConfig(effort)); - return await ai.models.generateContent({ - model, - contents: params.prompt, - config - }); - }).mapRej(toError).chain((r) => extractResponse({ provider: "gemini", source: "sdk", value: r })); - - return tryWithEffort( - effort.maybe<[EffortAttempt, ...EffortAttempt[]]>( - [() => mk("off")], - () => [() => mk("level"), () => mk("budget"), () => mk("off")] - ) - ); -}; - -// OAuth REST branch: same fallback, just builds `generationConfig.thinkingConfig` into the JSON body. -``` - -### 6d. Persisting the capability result - -Each adapter returns `Future>` instead of `Future`. [src/domain/llm/router.ts](src/domain/llm/router.ts) catches the result, hands the string back to [src/domain/commit/commit.ts](src/domain/commit/commit.ts) for the user-facing output, and writes the new `effort_support` value back into config via `saveConfig`. Exactly one `saveConfig` call per generate; it's a cheap JSON write. - -## 7. Verification - -1. **Build + lint + typecheck**: `pnpm run build && pnpm run typecheck && pnpm run lint:ci` — all clean. The `satisfies readonly OpenAIEffort[]` clauses must compile against the installed SDK's types. -2. **Schema back-compat**: existing `~/.commit-tools/config.json` (no `effort`/`effort_support`) loads; the `optionalMaybe` decoder yields `Nothing()`; generate still works with zero behaviour change. -3. **Slider values match SDK**: - - `model` on OpenAI shows **exactly** `none / minimal / low / medium / high / xhigh`. - - On Anthropic shows **exactly** `low / medium / high / max`. - - On Gemini shows **exactly** `MINIMAL / LOW / MEDIUM / HIGH`. -4. **Runtime fallback (per provider)** — run one real commit generation against each matrix row and inspect the effective request payload + the persisted `effort_support`: - - OpenAI api_key + `gpt-5-mini` + effort=`high` → request has `reasoning: { effort: "high" }`, config shows `supported`. - - OpenAI api_key + `gpt-4o` + effort=`high` → request fails on 400, retries without `reasoning`, commit still generated, config shows `unsupported` with reason; next `commit-tools model` on same model skips slider. - - Anthropic api_key + `claude-opus-4-6` + effort=`max` → request has `thinking: { type: "adaptive" }` + `output_config: { effort: "max" }`, config `supported`. - - Anthropic api_key + `claude-sonnet-4-5` + effort=`max` → adaptive fails → retries with `enabled + budget_tokens: 24576`, succeeds, config `supported` (different shape, same semantic). - - Anthropic setup_token + `claude-opus-4-6` + effort=`max` → adaptive may fail (no beta header) → falls through to `enabled`, commits succeed. - - Gemini api_key + `gemini-3-pro-preview` + effort=`HIGH` → request has `thinkingConfig.thinkingLevel: "HIGH"`, `supported`. - - Gemini api_key + `gemini-2.5-pro` + effort=`HIGH` → level rejected → retries with `thinkingBudget: 8192`, succeeds, `supported`. - - Gemini OAuth + `gemini-1.5-pro-latest` + any effort → both level and budget rejected → retries without thinking, succeeds, `unsupported`. -5. **Persistence**: confirm `~/.commit-tools/config.json` now contains `effort: { just: "high" }` and `effort_support: { just: { kind: "supported" } }` (or equivalent encoded form) after each scenario. -6. **Doctor**: `commit-tools doctor` prints the effort field cleanly. - -## 8. Out of scope - -- A dedicated `commit-tools effort` / `commit-tools effort --reset` subcommand (trivial follow-up once this lands; re-running `commit-tools model` already refreshes both fields). -- Anthropic `thinking.display: "summarized" | "omitted"` control — not exposed; we pass the SDK's default. -- Gemini `includeThoughts` toggle — we never surface thinking tokens to the user in commit output. -- Exact budget integers for power users (Anthropic `budget_tokens` / Gemini `thinkingBudget`) — we only map semantic tiers; a separate advanced prompt could add it later. -- Migrating the OAuth REST Gemini path entirely to `@google/genai` (the SDK exposes an OAuth-token client too, but the existing REST call keeps the diff smaller; a follow-up can consolidate). From 5e39388a8a7c9c7e0ddcff5d05744324b94904b7 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Thu, 23 Apr 2026 18:19:11 -0300 Subject: [PATCH 04/38] Add reasoning effort configuration for LLM providers - Introduce per-provider `effort` field on `ProviderConfig` for OpenAI, Anthropic, and Gemini, with SDK-backed value lists and compile-time exhaustiveness checks in `domain/config/config`. - Add `domain/llm/effort` module with param builders (`openaiReasoningParam`, `anthropicAdaptiveParam`, `anthropicEnabledParam`, `geminiLevelConfig`, `geminiBudgetConfig`) and helpers to seed and update provider config. - Add `infra/llm/effort-fallback` with `tryWithEffort` to retry API calls when the SDK rejects effort parameters. - Wire effort into `infra/llm/openai`, `infra/llm/anthropic`, and `infra/llm/gemini` call paths, including adaptive/enabled thinking stages for Anthropic and level/budget stages for Gemini. - Migrate Gemini integration from `@google/generative-ai` to `@google/genai` and adjust `response-parser` to the new SDK shape. - Add `infra/ui/effort-picker` and `infra/ui/effort-slider` Ink component, and prompt for effort during `setup` and `model` commands. - Preserve `effort` when refreshing OAuth tokens in `auth-resolver` and `infra/storage/config`. - Add `repo.getBaseBranch` using reflog parsing with fallback to `origin/HEAD`, and a `not-found` variant to `pr.PrLookup`. - Extend `doctor` with a git context section showing branch, base, and open PR rows. - Make push metadata fields optional in `cli/commit` and `infra/ui/push-note`, warning per field and skipping the note when nothing is available. - Surface the selected effort next to the model in `doctor` output via `renderModelInfo`. --- src/cli/commit.ts | 40 ++++++-- src/cli/doctor.ts | 79 +++++++++++++++- src/cli/model.ts | 13 +-- src/cli/setup.ts | 14 ++- src/domain/config/config.ts | 63 ++++++++++++- src/domain/llm/auth-resolver.ts | 30 ++++-- src/domain/llm/effort.ts | 149 ++++++++++++++++++++++++++++++ src/domain/llm/response-parser.ts | 10 +- src/infra/git/repo.ts | 60 ++++++++++++ src/infra/github/pr.ts | 12 ++- src/infra/llm/anthropic.ts | 142 ++++++++++++++++++++-------- src/infra/llm/effort-fallback.ts | 26 ++++++ src/infra/llm/gemini.ts | 133 +++++++++++++++++--------- src/infra/llm/openai.ts | 136 +++++++++++++++++---------- src/infra/storage/config.ts | 61 ++++++------ src/infra/ui/effort-picker.ts | 64 +++++++++++++ src/infra/ui/effort-slider.tsx | 120 ++++++++++++++++++++++++ src/infra/ui/push-note.ts | 36 +++++--- 18 files changed, 963 insertions(+), 225 deletions(-) create mode 100644 src/domain/llm/effort.ts create mode 100644 src/infra/llm/effort-fallback.ts create mode 100644 src/infra/ui/effort-picker.ts create mode 100644 src/infra/ui/effort-slider.tsx diff --git a/src/cli/commit.ts b/src/cli/commit.ts index 15e842a..7485069 100644 --- a/src/cli/commit.ts +++ b/src/cli/commit.ts @@ -89,25 +89,47 @@ class Commit { : publish ? "Published successfully!" : "Pushed successfully!"; + // TODO: We definetly need to remove these functions; If we need to do this complex thing, maybe the function signature needs to be changed to something more simple + // ====================================== // + const warnField = (label: string, err: Error): void => p.log.warn(color.yellow(`[${label}] ${err.message}`)); + + const optional = (label: string, f: Future): Future> => + f + .map((v): Maybe => Just(v)) + .chainRej((err): Future> => { + warnField(label, err); + return Future.resolve(Nothing()); + }); + + const recoverMaybe = (label: string, f: Future>): Future> => + f.chainRej((err): Future> => { + warnField(label, err); + return Future.resolve(Nothing()); + }); + // ====================================== // + return loading(startMsg, endMsg, repo.performPush(branch, publish, forceWithLease)).chain((result) => Future.concurrently< Error, { - commit: repo.CommitMetadata; - localBranch: string; - upstream: Maybe; - remoteUrl: string; + commit: Maybe; + localBranch: Maybe; + baseBranch: Maybe; + remoteUrl: Maybe; pr: pr.PrLookup; } >({ - commit: repo.getCommitMetadata(), - localBranch: repo.getCurrentBranch(), - upstream: repo.getUpstream(), - remoteUrl: repo.getTrackingRemoteUrl(), + commit: optional("commit", repo.getCommitMetadata()), + localBranch: optional("localBranch", repo.getCurrentBranch()), + baseBranch: recoverMaybe("baseBranch", repo.getBaseBranch()), + remoteUrl: optional("remoteUrl", repo.getTrackingRemoteUrl()), pr: pr.getOpenPullRequest() }) .map((parts) => renderPushNote({ ...parts, range: result.range })) - .chainRej(() => Future.resolve(undefined)) + .chainRej((err) => { + p.log.warn(color.yellow(`Could not render commit metadata: ${err.message}`)); + return Future.resolve(undefined); + }) ); } diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 60b9ea3..8e62279 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1,8 +1,13 @@ export { Doctor }; +import * as pr from "@/infra/github/pr"; +import * as repo from "@/infra/git/repo"; + import { Future } from "@/libs/future"; import { CONFIG_FILE, loadConfig } from "@/infra/storage/config"; import { type AuthMethod, type ProviderConfig } from "@/domain/config/config"; +import { Just, Nothing, type Maybe } from "@/libs/maybe"; +import { absurd } from "@/libs/types"; import { access } from "node:fs/promises"; import { environment } from "@/infra/env"; @@ -20,10 +25,12 @@ class Doctor { run(): Future { return this.checkOAuthCredentials().chain((oauthRow) => - this.checkConfig().map((configRows) => { - const rows: CheckRow[] = [this.checkRuntime(), this.checkPlatform(), oauthRow, ...configRows]; - this.renderTable(rows); - }) + this.checkConfig().chain((configRows) => + this.checkGitContext().map((gitRows) => { + const rows: CheckRow[] = [this.checkRuntime(), this.checkPlatform(), oauthRow, ...configRows, ...gitRows]; + this.renderTable(rows); + }) + ) ); } @@ -67,7 +74,7 @@ class Doctor { const rows: CheckRow[] = [row]; const ai = config.ai; - rows.push(["Provider", color.green(ai.provider), `Model: ${ai.model}`]); + rows.push(["Provider", color.green(ai.provider), renderModelInfo(ai)]); const authMethod = ai.auth_method.type; @@ -92,6 +99,36 @@ class Doctor { }); } + private checkGitContext(): Future { + return repo + .checkIsGitRepo() + .chain((): Future => this.collectGitRows()) + .chainRej( + (): Future => + Future.resolve([["Git Repository", color.yellow("Outside"), "Not a git repository"]]) + ); + } + + private collectGitRows(): Future { + // TODO: We definetly need to remove these functions; If we need to do this complex thing, maybe the function signature needs to be changed to something more simple + const optional = (f: Future): Future> => + f.map((v): Maybe => Just(v)).chainRej((): Future> => Future.resolve(Nothing())); + + // TODO: We definetly need to remove these functions; If we need to do this complex thing, maybe the function signature needs to be changed to something more simple + const recoverMaybe = (f: Future>): Future> => + f.chainRej((): Future> => Future.resolve(Nothing())); + + return Future.concurrently; base: Maybe; pr: pr.PrLookup }>({ + branch: optional(repo.getCurrentBranch()), + base: recoverMaybe(repo.getBaseBranch()), + pr: pr.getOpenPullRequest() + }).map(({ branch, base, pr: prLookup }): CheckRow[] => [ + renderBranchRow(branch), + renderBaseRow(base), + renderPrRow(prLookup) + ]); + } + private renderTable(rows: CheckRow[]): void { const table = new Table({ head: [color.cyan("Check"), color.cyan("Status"), color.cyan("Info")], @@ -113,6 +150,38 @@ class Doctor { } } +function renderBranchRow(branch: Maybe): CheckRow { + return branch instanceof Just ? + ["Branch", color.green("Current"), branch.value] + : ["Branch", color.yellow("Unknown"), "Could not read current branch"]; +} + +function renderBaseRow(base: Maybe): CheckRow { + return base instanceof Just ? + ["Base", color.green("Detected"), base.value] + : ["Base", color.yellow("Unknown"), "Could not resolve base branch"]; +} + +function renderPrRow(lookup: pr.PrLookup): CheckRow { + switch (lookup.type) { + case "found": + return ["Pull Request", color.green("Open"), `#${lookup.pr.number} ${lookup.pr.url}`]; + case "not-found": + return ["Pull Request", color.yellow("None"), "No open PR for this branch"]; + case "unauthenticated": + return ["Pull Request", color.yellow("Auth"), "Run 'gh auth login' to enable PR lookup"]; + case "unavailable": + return ["Pull Request", color.gray("Skipped"), "gh not installed or remote is not GitHub"]; + default: + return absurd(lookup, "PrLookup"); + } +} + +function renderModelInfo(ai: ProviderConfig): string { + const base = `${ai.model}`; + return ai.effort instanceof Just ? `${base} (${ai.effort.value} effort)` : base; +} + function authMethodLabel(authMethod: AuthMethod): string { switch (authMethod) { case "google_oauth": diff --git a/src/cli/model.ts b/src/cli/model.ts index fb83e06..784ac39 100644 --- a/src/cli/model.ts +++ b/src/cli/model.ts @@ -6,6 +6,7 @@ import { Future } from "@/libs/future"; import { type Config, type ProviderConfig } from "@/domain/config/config"; import { loadConfig, saveConfig } from "@/infra/storage/config"; import { resolveProvider } from "@/domain/llm/auth-resolver"; +import { selectEffortForProvider, withModel } from "@/domain/llm/effort"; import { fetchModels } from "@/domain/commit/models"; import { selectModelInteractively } from "@/infra/ui/model-picker"; import { loading } from "@/infra/ui/spinner"; @@ -35,15 +36,9 @@ class ModelCommand { fetchModels(this.providerConfig.provider, this.providerConfig.auth_method) ) .chain((models) => selectModelInteractively(models)) - .chain((modelId) => - saveConfig({ - ...this.config, - ai: { ...this.config.ai, model: modelId } - }) - ) - .map(() => { - p.outro(color.green("Model updated successfully!")); - }) + .chain((modelId) => selectEffortForProvider(withModel(this.config.ai, modelId))) + .chain((ai) => saveConfig({ ...this.config, ai })) + .map(() => p.outro(color.green("Model updated successfully!"))) .mapRej((e) => { p.log.error(color.red(e.message)); return e; diff --git a/src/cli/setup.ts b/src/cli/setup.ts index d956470..dbd6c2f 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -1,9 +1,9 @@ export { Setup }; import * as p from "@clack/prompts"; -import type { Option } from "@clack/prompts"; import { Future } from "@/libs/future"; +import { type Option } from "@clack/prompts"; import { saveConfig } from "@/infra/storage/config"; import { CommitConvention, type Config, type ProviderConfig } from "@/domain/config/config"; import { performOAuthFlow, validateOAuthTokens } from "@/infra/auth/google"; @@ -13,6 +13,7 @@ import { Just, Nothing } from "@/libs/maybe"; import { loading } from "@/infra/ui/spinner"; import { fetchModels } from "@/domain/commit/models"; import { selectModelInteractively } from "@/infra/ui/model-picker"; +import { selectEffortForProvider, seedProviderConfig } from "@/domain/llm/effort"; import color from "picocolors"; @@ -94,13 +95,9 @@ class Setup { } } - private buildConfig(authMethod: ProviderConfig["auth_method"], model: string): Config { + private buildConfig(ai: ProviderConfig): Config { return { - ai: { - provider: this.preferences.provider, - model, - auth_method: authMethod - }, + ai, commit_convention: this.preferences.convention, custom_template: this.preferences.customTemplate ? Just(this.preferences.customTemplate) : Nothing() }; @@ -161,7 +158,8 @@ class Setup { fetchModels(this.preferences.provider, authMethod) ) .chain((models) => selectModelInteractively(models)) - .chain((modelId) => saveConfig(this.buildConfig(authMethod, modelId))) + .chain((modelId) => selectEffortForProvider(seedProviderConfig(this.preferences.provider, modelId, authMethod))) + .chain((ai) => saveConfig(this.buildConfig(ai))) .map(() => { p.outro(color.green("Setup complete!")); }) diff --git a/src/domain/config/config.ts b/src/domain/config/config.ts index 4715c2c..dcea6f2 100644 --- a/src/domain/config/config.ts +++ b/src/domain/config/config.ts @@ -5,18 +5,29 @@ export { type RefreshTokens, type AuthMethod, type ProviderConfig, + type OpenAIEffort, + type AnthropicEffort, + type GeminiEffort, type Model, Config, schema_OAuthTokens, schema_OpenAITokens, schema_AuthMethod, schema_ProviderConfig, + AI_PROVIDERS, COMMIT_CONVENTIONS, - AI_PROVIDERS + OPENAI_EFFORTS, + ANTHROPIC_EFFORTS, + GEMINI_EFFORTS }; import * as s from "@/libs/json/schema"; +import { ThinkingLevel } from "@google/genai"; + +import type OpenAIPkg from "openai"; +import type AnthropicPkg from "@anthropic-ai/sdk"; + const COMMIT_CONVENTIONS = ["conventional", "imperative", "custom"] as const; type CommitConvention = (typeof COMMIT_CONVENTIONS)[number]; @@ -60,21 +71,65 @@ const schema_AuthMethod = s.discriminatedUnion([ ]); type AuthMethod = s.Infer["type"]; +// TODO: omg, what is that? +// Effort value arrays — the one runtime listing the UI and schema need. +// +// Provider notes on "why the hand-written list": +// - OpenAI's `ReasoningEffort` and Anthropic's `OutputConfig.effort` are both +// TypeScript string-union types. TS types don't exist at runtime, so we +// CAN'T iterate them — the values must be enumerated. Safety comes from +// two complementary compile-time checks: +// 1. `satisfies readonly NonNullable[]` → each element is valid. +// 2. `assertExhaustive<...>()` → SDK has no extra value. +// If a pinned SDK adds or removes a value, the build breaks immediately. +// - Gemini's `ThinkingLevel` IS a real (runtime) enum, so we derive the +// array directly via `Object.values`. No hand-written list at all. +const OPENAI_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const satisfies readonly NonNullable< + OpenAIPkg.Reasoning["effort"] +>[]; +const ANTHROPIC_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const satisfies readonly NonNullable< + AnthropicPkg.OutputConfig["effort"] +>[]; + +const isNamedThinkingLevel = ( + v: ThinkingLevel +): v is Exclude => + v !== ThinkingLevel.THINKING_LEVEL_UNSPECIFIED; + +const GEMINI_EFFORTS = Object.values(ThinkingLevel).filter(isNamedThinkingLevel); + +type OpenAIEffort = (typeof OPENAI_EFFORTS)[number]; +type AnthropicEffort = (typeof ANTHROPIC_EFFORTS)[number]; +type GeminiEffort = (typeof GEMINI_EFFORTS)[number]; + +// Reverse-direction guard: every SDK-declared value must appear in our array. +// `satisfies` catches "array has invalid value"; this catches "SDK added a +// value we haven't listed yet". Together they lock the two sides together. +type AssertEmpty = [T] extends [never] ? true : ["Missing SDK effort values", T]; +const _openaiCoversSdk: AssertEmpty, OpenAIEffort>> = true; +const _anthropicCoversSdk: AssertEmpty, AnthropicEffort>> = + true; +void _openaiCoversSdk; +void _anthropicCoversSdk; + const schema_ProviderConfig = s.discriminatedUnion([ s.variant({ provider: "gemini", model: s.string, - auth_method: schema_AuthMethod + auth_method: schema_AuthMethod, + effort: s.optionalMaybe(s.stringEnum([...GEMINI_EFFORTS])) }), s.variant({ provider: "openai", model: s.string, - auth_method: schema_AuthMethod + auth_method: schema_AuthMethod, + effort: s.optionalMaybe(s.stringEnum([...OPENAI_EFFORTS])) }), s.variant({ provider: "anthropic", model: s.string, - auth_method: schema_AuthMethod + auth_method: schema_AuthMethod, + effort: s.optionalMaybe(s.stringEnum([...ANTHROPIC_EFFORTS])) }) ]); type ProviderConfig = s.Infer; diff --git a/src/domain/llm/auth-resolver.ts b/src/domain/llm/auth-resolver.ts index c73328f..2c7e902 100644 --- a/src/domain/llm/auth-resolver.ts +++ b/src/domain/llm/auth-resolver.ts @@ -31,6 +31,20 @@ const refreshAndPersist: RefreshAndPersistFlow = (tokens, refresh, persist) => ) ); +// TODO: WHY????? +const withAuthMethod = (ai: ProviderConfig, auth_method: ProviderConfig["auth_method"]): ProviderConfig => { + switch (ai.provider) { + case "openai": + return { provider: "openai", model: ai.model, auth_method, effort: ai.effort }; + case "anthropic": + return { provider: "anthropic", model: ai.model, auth_method, effort: ai.effort }; + case "gemini": + return { provider: "gemini", model: ai.model, auth_method, effort: ai.effort }; + default: + return absurd(ai, "ProviderConfig"); + } +}; + const resolveProvider: ResolveProvider = (config) => { const { ai } = config; @@ -40,18 +54,14 @@ const resolveProvider: ResolveProvider = (config) => { return Future.resolve(ai); case "google_oauth": - return refreshAndPersist(ai.auth_method.content, ensureFreshTokens, updateGoogleTokens).map((tokens) => ({ - provider: ai.provider, - model: ai.model, - auth_method: { type: "google_oauth", content: tokens } - })); + return refreshAndPersist(ai.auth_method.content, ensureFreshTokens, updateGoogleTokens).map((tokens) => + withAuthMethod(ai, { type: "google_oauth", content: tokens }) + ); case "openai_oauth": - return refreshAndPersist(ai.auth_method.content, ensureFreshOpenAITokens, updateOpenAITokens).map((tokens) => ({ - provider: ai.provider, - model: ai.model, - auth_method: { type: "openai_oauth", content: tokens } - })); + return refreshAndPersist(ai.auth_method.content, ensureFreshOpenAITokens, updateOpenAITokens).map((tokens) => + withAuthMethod(ai, { type: "openai_oauth", content: tokens }) + ); default: return absurd(ai.auth_method, "AuthMethod"); diff --git a/src/domain/llm/effort.ts b/src/domain/llm/effort.ts new file mode 100644 index 0000000..23e0b8c --- /dev/null +++ b/src/domain/llm/effort.ts @@ -0,0 +1,149 @@ +export { + openaiReasoningParam, + anthropicAdaptiveParam, + anthropicEnabledParam, + geminiLevelConfig, + geminiBudgetConfig, + seedProviderConfig, + withModel, + selectEffortForProvider +}; + +import { type ThinkingConfig, ThinkingLevel } from "@google/genai"; +import { type Future } from "@/libs/future"; +import { Nothing, type Maybe } from "@/libs/maybe"; +import { + type ProviderConfig, + type OpenAIEffort, + type AnthropicEffort, + type GeminiEffort +} from "@/domain/config/config"; +import { selectOpenAIEffort, selectAnthropicEffort, selectGeminiEffort } from "@/infra/ui/effort-picker"; +import { absurd } from "@/libs/types"; + +import type OpenAI from "openai"; +import type Anthropic from "@anthropic-ai/sdk"; + +const openaiReasoningParam = (effort: Maybe): { reasoning: OpenAI.Reasoning } | undefined => + effort.maybe<{ reasoning: OpenAI.Reasoning } | undefined>(undefined, (e) => ({ + reasoning: { effort: e } + })); + +const anthropicAdaptiveParam = ( + effort: Maybe +): { thinking: Anthropic.ThinkingConfigAdaptive; output_config: Anthropic.OutputConfig } | undefined => + effort.maybe<{ thinking: Anthropic.ThinkingConfigAdaptive; output_config: Anthropic.OutputConfig } | undefined>( + undefined, + (e) => ({ + thinking: { type: "adaptive" }, + output_config: { effort: e } + }) + ); + +const BUDGET_BY_EFFORT: Record = { + low: 1024, + medium: 4096, + high: 16384, + xhigh: 20480, + max: 24576 +}; + +const anthropicEnabledParam = ( + effort: Maybe, + baseMaxTokens: number +): { thinking: Anthropic.ThinkingConfigEnabled; max_tokens: number } | undefined => + effort.maybe<{ thinking: Anthropic.ThinkingConfigEnabled; max_tokens: number } | undefined>(undefined, (e) => { + const budget = BUDGET_BY_EFFORT[e]; + return { + thinking: { type: "enabled", budget_tokens: budget }, + max_tokens: Math.max(baseMaxTokens, budget + 1024) + }; + }); + +const LEVEL_MAP: Record = { + MINIMAL: ThinkingLevel.MINIMAL, + LOW: ThinkingLevel.LOW, + MEDIUM: ThinkingLevel.MEDIUM, + HIGH: ThinkingLevel.HIGH +}; + +const geminiLevelConfig = (effort: Maybe): { thinkingConfig: ThinkingConfig } | undefined => + effort.maybe<{ thinkingConfig: ThinkingConfig } | undefined>(undefined, (e) => ({ + thinkingConfig: { thinkingLevel: LEVEL_MAP[e] } + })); + +const BUDGET_BY_LEVEL: Record = { + MINIMAL: 128, + LOW: 512, + MEDIUM: 2048, + HIGH: 8192 +}; + +const geminiBudgetConfig = (effort: Maybe): { thinkingConfig: ThinkingConfig } | undefined => + effort.maybe<{ thinkingConfig: ThinkingConfig } | undefined>(undefined, (e) => ({ + thinkingConfig: { thinkingBudget: BUDGET_BY_LEVEL[e] } + })); + +const seedProviderConfig = ( + provider: ProviderConfig["provider"], + model: string, + auth_method: ProviderConfig["auth_method"] +): ProviderConfig => { + switch (provider) { + case "openai": + return { provider, model, auth_method, effort: Nothing() }; + case "anthropic": + return { provider, model, auth_method, effort: Nothing() }; + case "gemini": + return { provider, model, auth_method, effort: Nothing() }; + default: + return absurd(provider, "provider"); + } +}; + +const withModel = (ai: ProviderConfig, model: string): ProviderConfig => { + switch (ai.provider) { + case "openai": + return { provider: "openai", model, auth_method: ai.auth_method, effort: ai.effort }; + case "anthropic": + return { provider: "anthropic", model, auth_method: ai.auth_method, effort: ai.effort }; + case "gemini": + return { provider: "gemini", model, auth_method: ai.auth_method, effort: ai.effort }; + default: + return absurd(ai, "ProviderConfig"); + } +}; + +const selectEffortForProvider = (current: ProviderConfig): Future => { + switch (current.provider) { + case "openai": + return selectOpenAIEffort(current.model, current.effort).map( + (effort): ProviderConfig => ({ + provider: "openai", + model: current.model, + auth_method: current.auth_method, + effort + }) + ); + case "anthropic": + return selectAnthropicEffort(current.model, current.effort).map( + (effort): ProviderConfig => ({ + provider: "anthropic", + model: current.model, + auth_method: current.auth_method, + effort + }) + ); + case "gemini": + return selectGeminiEffort(current.model, current.effort).map( + (effort): ProviderConfig => ({ + provider: "gemini", + model: current.model, + auth_method: current.auth_method, + effort + }) + ); + default: + return absurd(current, "ProviderConfig"); + } +}; diff --git a/src/domain/llm/response-parser.ts b/src/domain/llm/response-parser.ts index 37b0ac3..29ef5b3 100644 --- a/src/domain/llm/response-parser.ts +++ b/src/domain/llm/response-parser.ts @@ -13,7 +13,7 @@ const finalizeText = (raw: string | null | undefined): Future => type TextBlock = { type: "text"; text: string }; type AnthropicContent = Array<{ type: string; text?: string }>; -type GeminiSDKLike = { response: { text: () => string | null | undefined } }; +type GeminiSDKLike = { text?: string | null | undefined }; type GeminiRESTLike = { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; }; @@ -54,9 +54,12 @@ const extractOpenAIStreamText = (raw: OpenAIStreamLike): string => { const extractResponse = (raw: RawResponse): Future => { switch (raw.provider) { case "gemini": + // TODO: THIS NEEDS TO BE REVIEWED: + // 1. Why do we have two different response shapes for Gemini? SDK vs REST? We just need to have one. + // 2. Why we have two? switch (raw.source) { case "sdk": - return finalizeText(raw.value.response.text()); + return finalizeText(raw.value.text); case "rest": return finalizeText(raw.value.candidates?.[0]?.content?.parts?.[0]?.text); default: @@ -65,6 +68,9 @@ const extractResponse = (raw: RawResponse): Future => { case "anthropic": return finalizeText(extractAnthropicText(raw.value.content)); case "openai": + // TODO: THIS NEEDS TO BE REVIEWED: + // 1. Why do we have two different response shapes for OpenAI? What means "direct" vs "stream"? Can't we unify this in the infra layer and have just one shape here? We just need to have one. + // 2. Why we have two? the answer is the same as above, only one response shape is needed. switch (raw.source) { case "direct": return finalizeText(raw.value.output_text); diff --git a/src/infra/git/repo.ts b/src/infra/git/repo.ts index 3a568bb..2bc5efe 100644 --- a/src/infra/git/repo.ts +++ b/src/infra/git/repo.ts @@ -6,6 +6,7 @@ export { getCurrentBranch, hasUpstream, getUpstream, + getBaseBranch, getCommitMetadata, getRemoteUrl, getTrackingRemoteUrl, @@ -16,6 +17,8 @@ export { import { Future } from "@/libs/future"; import { Just, Nothing, type Maybe } from "@/libs/maybe"; +import { type Result, Success, Failure } from "@/libs/result"; +import { absurd } from "@/libs/types"; import { execBin } from "@/infra/shell"; import { unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -37,6 +40,13 @@ type PushResult = { range: Maybe; }; +type BaseLookupError = + | { type: "reflog-empty" } + | { type: "reflog-not-creation"; subject: string } + | { type: "reflog-cmd-failed"; stderr: string }; + +const CREATED_FROM_RE = /^branch: Created from (\S+)$/; + const execGitChecked = (args: string[], fallbackMsg: string): Future => execBin("git", args).chain(({ stdout, stderr, exitCode }) => exitCode !== 0 ? @@ -106,6 +116,55 @@ const getUpstream = (): Future> => exitCode !== 0 ? Nothing() : Just(stdout.trim()) ); +const oldestReflogSubject = (stdout: string): Result => { + const oldest = stdout.split("\n").filter(Boolean).at(-1); + return oldest ? Success(oldest) : Failure({ type: "reflog-empty" }); +}; + +const parseCreatedFrom = (subject: string): Result => { + const source = subject.match(CREATED_FROM_RE)?.[1]; + return source && source !== "HEAD" ? Success(source) : Failure({ type: "reflog-not-creation", subject }); +}; + +const normalizeBranchRef = (ref: string): string => ref.replace(/^refs\/heads\//, ""); + +const parseBaseFromReflog = (stdout: string): Result => + oldestReflogSubject(stdout).chain(parseCreatedFrom).map(normalizeBranchRef); + +const getBaseFromReflog = (branch: string): Future> => + execBin("git", ["log", "-g", "--format=%gs", branch]).map(({ stdout, stderr, exitCode }) => + // TODO: exitCode !== 0; why we need this? is there something we could move to handle inside some custom helper function like execGitChecked? + exitCode !== 0 ? + Failure({ type: "reflog-cmd-failed", stderr: stderr.trim() }) + : parseBaseFromReflog(stdout) + ); + +const getDefaultRemoteBranch = (): Future> => + execBin("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]).map(({ stdout, exitCode }) => + // TODO: The same + exitCode !== 0 ? Nothing() : Just(stdout.trim().replace(/^origin\//, "")) + ); + +const getBaseBranch = (): Future> => + getCurrentBranch().chain((branch) => + getBaseFromReflog(branch).chain((result) => + result.either( + (err) => { + switch (err.type) { + case "reflog-empty": + case "reflog-not-creation": + return getDefaultRemoteBranch(); + case "reflog-cmd-failed": + return Future.reject>(new Error(`git reflog failed: ${err.stderr}`)); + default: + return absurd(err, "BaseLookupError"); + } + }, + (base) => Future.resolve>(Just(base)) + ) + ) + ); + const getRemoteUrl = (remote: string = "origin"): Future => execGitChecked(["remote", "get-url", remote], `Failed to read remote '${remote}' url`).map((s) => s.trim()); @@ -124,6 +183,7 @@ const getCommitMetadata = (ref: string = "HEAD"): Future execGitChecked(["log", "-1", `--format=%H%n%h%n%s%n%an%n%ae%n%aI`, ref], "Failed to read commit metadata").chain( (stdout) => { const [hash, short, subject, authorName, authorEmail, iso] = stdout.split("\n"); + // TODO: This is specially hard to understand and maintain, consider using a more robust serialization format in the future (e.g. JSON output from git log with a custom format) return hash && short && subject !== undefined && authorName !== undefined && authorEmail !== undefined && iso ? Future.resolve({ hash, short, subject, authorName, authorEmail, date: new Date(iso) }) : Future.reject(new Error("Malformed git log output")); diff --git a/src/infra/github/pr.ts b/src/infra/github/pr.ts index 73bb6b4..ca30725 100644 --- a/src/infra/github/pr.ts +++ b/src/infra/github/pr.ts @@ -9,10 +9,16 @@ import { execBin } from "@/infra/shell"; type PullRequest = { url: string; number: number }; -type PrLookup = { type: "found"; pr: PullRequest } | { type: "unauthenticated" } | { type: "unavailable" }; +type PrLookup = + | { type: "found"; pr: PullRequest } + | { type: "not-found" } + | { type: "unauthenticated" } + | { type: "unavailable" }; +// TODO: This looks like a "magical number", we need to think more about this const GITHUB_REPO_RE = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/; const GH_UNAUTH_RE = /not logged into|gh auth login|authentication required/i; +const GH_NOT_FOUND_RE = /no pull requests? found/i; const parseGithubRepo = (url: string): Maybe => { const m = url.match(GITHUB_REPO_RE); @@ -31,7 +37,9 @@ const parsePrJson = (stdout: string): PrLookup => ); const classifyFailure = (stderr: string): PrLookup => - GH_UNAUTH_RE.test(stderr) ? { type: "unauthenticated" } : { type: "unavailable" }; + GH_UNAUTH_RE.test(stderr) ? { type: "unauthenticated" } + : GH_NOT_FOUND_RE.test(stderr) ? { type: "not-found" } + : { type: "unavailable" }; const getOpenPullRequest = (): Future => Future.both(repo.getTrackingRemoteUrl(), repo.getCurrentBranch()) diff --git a/src/infra/llm/anthropic.ts b/src/infra/llm/anthropic.ts index bc20a37..71cb220 100644 --- a/src/infra/llm/anthropic.ts +++ b/src/infra/llm/anthropic.ts @@ -2,60 +2,126 @@ export { generateContentWithAnthropic }; import Anthropic from "@anthropic-ai/sdk"; -import { type Config } from "@/domain/config/config"; +import { type Config, type AnthropicEffort } from "@/domain/config/config"; import { type GenerateContentParams } from "@/domain/llm/router"; import { Future } from "@/libs/future"; import { anthropicOAuthHeaders, CLAUDE_CODE_SYSTEM_PROMPT } from "@/infra/auth/anthropic"; import { absurd } from "@/libs/types"; import { extractResponse } from "@/domain/llm/response-parser"; +import { anthropicAdaptiveParam, anthropicEnabledParam } from "@/domain/llm/effort"; +import { tryWithEffort, type EffortAttempt } from "@/infra/llm/effort-fallback"; +import { type Maybe } from "@/libs/maybe"; type AnthropicConfig = Extract; type TextBlock = { type: "text"; text: string }; +type Stage = "adaptive" | "enabled" | "off"; + +const BASE_MAX_TOKENS = 4096; + +// TODO: We really need this? const toError = (error: unknown): Error => (error instanceof Error ? error : new Error(String(error))); -const callAnthropicWithApiKey = (apiKey: string, model: string, params: GenerateContentParams): Future => - Future.attemptP(async () => { - const client = new Anthropic({ apiKey }); +const buildApiKeyParams = ( + model: string, + effort: Maybe, + params: GenerateContentParams, + stage: Stage +): Anthropic.MessageCreateParamsNonStreaming => { + const base: Anthropic.MessageCreateParamsNonStreaming = { + model, + max_tokens: BASE_MAX_TOKENS, + ...(params.systemInstruction !== undefined ? { system: params.systemInstruction } : {}), + messages: [{ role: "user", content: params.prompt }] + }; + + // TODO: That's is interesting. These both if's are wrong. We don't want the adaptive. and we don't want to verify if the effort is enabled. The effort should be ever enabled. If any effort level is set, use the "medium" or "high" as default. + if (stage === "adaptive") { + const adaptive = anthropicAdaptiveParam(effort); + return adaptive ? { ...base, ...adaptive } : base; + } + if (stage === "enabled") { + const enabled = anthropicEnabledParam(effort, BASE_MAX_TOKENS); + return enabled ? { ...base, thinking: enabled.thinking, max_tokens: enabled.max_tokens } : base; + } + return base; +}; + +const buildSetupTokenParams = ( + model: string, + effort: Maybe, + params: GenerateContentParams, + stage: Stage +): Anthropic.MessageCreateParamsNonStreaming => { + const systemBlocks: TextBlock[] = [{ type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }]; + if (params.systemInstruction !== undefined) { + systemBlocks.push({ type: "text", text: params.systemInstruction }); + } + + const base: Anthropic.MessageCreateParamsNonStreaming = { + model, + max_tokens: BASE_MAX_TOKENS, + system: systemBlocks, + messages: [{ role: "user", content: params.prompt }] + }; + + if (stage === "adaptive") { + const adaptive = anthropicAdaptiveParam(effort); + return adaptive ? { ...base, ...adaptive } : base; + } + if (stage === "enabled") { + const enabled = anthropicEnabledParam(effort, BASE_MAX_TOKENS); + return enabled ? { ...base, thinking: enabled.thinking, max_tokens: enabled.max_tokens } : base; + } + return base; +}; + +const callAnthropicWithApiKey = ( + apiKey: string, + model: string, + effort: Maybe, + params: GenerateContentParams +): Future => { + const run = (stage: Stage): Future => + Future.attemptP(async () => { + const client = new Anthropic({ apiKey }); + return await client.messages.create(buildApiKeyParams(model, effort, params, stage)); + }) + .mapRej(toError) + .chain((response) => extractResponse({ provider: "anthropic", value: response })); - return await client.messages.create({ - model, - max_tokens: 4096, - ...(params.systemInstruction !== undefined ? { system: params.systemInstruction } : {}), - messages: [{ role: "user", content: params.prompt }] - }); - }) - .mapRej(toError) - .chain((response) => extractResponse({ provider: "anthropic", value: response })); + return tryWithEffort(buildAttempts(effort, run)); +}; const callAnthropicWithSetupToken = ( authToken: string, model: string, + effort: Maybe, params: GenerateContentParams -): Future => - Future.attemptP(async () => { - const client = new Anthropic({ - apiKey: null, - authToken, - defaultHeaders: anthropicOAuthHeaders() - }); - - const systemBlocks: TextBlock[] = [{ type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }]; - - if (params.systemInstruction !== undefined) { - systemBlocks.push({ type: "text", text: params.systemInstruction }); - } - - return await client.messages.create({ - model, - max_tokens: 4096, - system: systemBlocks, - messages: [{ role: "user", content: params.prompt }] - }); - }) - .mapRej(toError) - .chain((response) => extractResponse({ provider: "anthropic", value: response })); +): Future => { + const run = (stage: Stage): Future => + Future.attemptP(async () => { + const client = new Anthropic({ + apiKey: null, + authToken, + defaultHeaders: anthropicOAuthHeaders() + }); + return await client.messages.create(buildSetupTokenParams(model, effort, params, stage)); + }) + .mapRej(toError) + .chain((response) => extractResponse({ provider: "anthropic", value: response })); + + return tryWithEffort(buildAttempts(effort, run)); +}; + +const buildAttempts = ( + effort: Maybe, + run: (stage: Stage) => Future +): readonly [EffortAttempt, ...EffortAttempt[]] => + anthropicAdaptiveParam(effort) !== undefined ? + [() => run("adaptive"), () => run("enabled"), () => run("off")] + : [() => run("off")]; const generateContentWithAnthropic = ( config: AnthropicConfig, @@ -63,9 +129,9 @@ const generateContentWithAnthropic = ( ): Future => { switch (config.auth_method.type) { case "api_key": - return callAnthropicWithApiKey(config.auth_method.content, config.model, params); + return callAnthropicWithApiKey(config.auth_method.content, config.model, config.effort, params); case "anthropic_setup_token": - return callAnthropicWithSetupToken(config.auth_method.content, config.model, params); + return callAnthropicWithSetupToken(config.auth_method.content, config.model, config.effort, params); case "google_oauth": case "openai_oauth": return Future.reject(new Error(`Unsupported auth method for Anthropic: ${config.auth_method.type}`)); diff --git a/src/infra/llm/effort-fallback.ts b/src/infra/llm/effort-fallback.ts new file mode 100644 index 0000000..73fec11 --- /dev/null +++ b/src/infra/llm/effort-fallback.ts @@ -0,0 +1,26 @@ +// TODO: I don't know what of these is the worst. We need to review all of this and probably change the function signatures to something more simple, instead of doing this complex thing with "Maybe" and "Future" and "EffortAttempt" and all that. We just need a simple function that takes a list of attempts and tries them one by one until it succeeds or runs out of attempts. That's it. No need for all this complexity. +export { tryWithEffort, type EffortAttempt }; + +import { Future } from "@/libs/future"; + +type EffortAttempt = () => Future; + +const EFFORT_FIELD_RE = + /reasoning|thinking|thinking_config|output_config|budget_tokens|thinkingconfig|thinkinglevel|thinkingbudget/i; +const BAD_REQUEST_RE = /(\b400\b|invalid_request|unsupported_parameter|bad_request|invalid_parameter)/i; + +const isEffortRejection = (err: Error): boolean => { + const msg = err.message; + return EFFORT_FIELD_RE.test(msg) && BAD_REQUEST_RE.test(msg); +}; + +const tryWithEffort = (attempts: readonly [EffortAttempt, ...EffortAttempt[]]): Future => { + const walk = (fn: EffortAttempt, remaining: readonly EffortAttempt[]): Future => + fn().chainRej((err) => { + const next = remaining[0]; + if (next === undefined || !isEffortRejection(err)) return Future.reject(err); + return walk(next, remaining.slice(1)); + }); + + return walk(attempts[0], attempts.slice(1)); +}; diff --git a/src/infra/llm/gemini.ts b/src/infra/llm/gemini.ts index 3d28a38..826f21a 100644 --- a/src/infra/llm/gemini.ts +++ b/src/infra/llm/gemini.ts @@ -1,12 +1,15 @@ export { type GeminiAuthCredentials, generateContentWithGemini, getAuthCredentials }; -import { GoogleGenerativeAI } from "@google/generative-ai"; +import { GoogleGenAI, type GenerateContentConfig } from "@google/genai"; + import { Future } from "@/libs/future"; -import { type Config, type OAuthTokens } from "@/domain/config/config"; +import { type Config, type OAuthTokens, type GeminiEffort } from "@/domain/config/config"; import { getAccessToken } from "@/infra/auth/google"; import { Just, Nothing, type Maybe } from "@/libs/maybe"; import { type GenerateContentParams } from "@/domain/llm/router"; import { extractResponse } from "@/domain/llm/response-parser"; +import { geminiLevelConfig, geminiBudgetConfig } from "@/domain/llm/effort"; +import { tryWithEffort, type EffortAttempt } from "@/infra/llm/effort-fallback"; type GeminiConfig = Extract; @@ -14,6 +17,8 @@ type GeminiAuthCredentials = | { readonly method: "api_key"; readonly apiKey: string } | { readonly method: "google_oauth"; readonly tokens: OAuthTokens }; +type Stage = "level" | "budget" | "off"; + const toError = (error: unknown): Error => (error instanceof Error ? error : new Error(String(error))); const getAuthCredentials = (config: Config): Maybe => { @@ -29,73 +34,109 @@ const getAuthCredentials = (config: Config): Maybe => { } }; +const buildConfigForStage = ( + effort: Maybe, + params: GenerateContentParams, + stage: Stage +): GenerateContentConfig => { + const base: GenerateContentConfig = {}; + if (params.systemInstruction !== undefined) base.systemInstruction = params.systemInstruction; + if (stage === "level") Object.assign(base, geminiLevelConfig(effort)); + if (stage === "budget") Object.assign(base, geminiBudgetConfig(effort)); + return base; +}; + +const buildAttempts = ( + effort: Maybe, + run: (stage: Stage) => Future +): readonly [EffortAttempt, ...EffortAttempt[]] => + geminiLevelConfig(effort) !== undefined ? + [() => run("level"), () => run("budget"), () => run("off")] + : [() => run("off")]; + const generateContentWithApiKey = ( apiKey: string, model: string, + effort: Maybe, params: GenerateContentParams ): Future => { - const genAI = new GoogleGenerativeAI(apiKey); - const modelParams: Parameters[0] = { model }; + const run = (stage: Stage): Future => + Future.attemptP(async () => { + const ai = new GoogleGenAI({ apiKey }); + const config = buildConfigForStage(effort, params, stage); + return await ai.models.generateContent({ model, contents: params.prompt, config }); + }) + .mapRej(toError) + .chain((result) => extractResponse({ provider: "gemini", source: "sdk", value: result })); + + return tryWithEffort(buildAttempts(effort, run)); +}; + +const buildOAuthBody = ( + effort: Maybe, + params: GenerateContentParams, + stage: Stage +): Record => { + const body: Record = { + contents: [{ parts: [{ text: params.prompt }] }] + }; if (params.systemInstruction !== undefined) { - modelParams.systemInstruction = params.systemInstruction; + body["system_instruction"] = { parts: [{ text: params.systemInstruction }] }; } - const geminiModel = genAI.getGenerativeModel(modelParams); + // TODO: maybe we don't need to check any of this, we could have a default set for each. + const levelCfg = stage === "level" ? geminiLevelConfig(effort) : undefined; + const budgetCfg = stage === "budget" ? geminiBudgetConfig(effort) : undefined; + const thinking = levelCfg ?? budgetCfg; + if (thinking) body["generationConfig"] = { thinkingConfig: thinking.thinkingConfig }; - return Future.attemptP(async () => await geminiModel.generateContent(params.prompt)) - .mapRej(toError) - .chain((result) => extractResponse({ provider: "gemini", source: "sdk", value: result })); + return body; }; const generateContentWithOAuth = ( tokens: OAuthTokens, model: string, + effort: Maybe, params: GenerateContentParams ): Future => - getAccessToken(tokens).chain((accessToken) => - Future.attemptP(async () => { - const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; - - const contents = [{ parts: [{ text: params.prompt }] }]; - const body: Record = { contents }; - - if (params.systemInstruction !== undefined) { - body["system_instruction"] = { - parts: [{ text: params.systemInstruction }] + getAccessToken(tokens).chain((accessToken) => { + const run = (stage: Stage): Future => + Future.attemptP(async () => { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(buildOAuthBody(effort, params, stage)) + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`Gemini API error (${response.status}): ${errorBody}`); + } + + // TODO: Remove the type-cast and review the types of this, and try to move this to use via SDK; + return (await response.json()) as { + promptFeedback?: unknown; + usageMetadata?: unknown; + candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; }; - } - - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json" - }, - body: JSON.stringify(body) - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`Gemini API error (${response.status}): ${errorBody}`); - } - - return (await response.json()) as { - promptFeedback?: unknown; - usageMetadata?: unknown; - candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; - }; - }) - .mapRej(toError) - .chain((json) => extractResponse({ provider: "gemini", source: "rest", value: json })) - ); + }) + .mapRej(toError) + .chain((json) => extractResponse({ provider: "gemini", source: "rest", value: json })); + + return tryWithEffort(buildAttempts(effort, run)); + }); const generateContentWithGemini = (config: GeminiConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { case "api_key": - return generateContentWithApiKey(config.auth_method.content, config.model, params); + return generateContentWithApiKey(config.auth_method.content, config.model, config.effort, params); case "google_oauth": - return generateContentWithOAuth(config.auth_method.content, config.model, params); + return generateContentWithOAuth(config.auth_method.content, config.model, config.effort, params); default: return Future.reject(new Error(`Unsupported auth method for Gemini: ${config.auth_method.type}`)); } diff --git a/src/infra/llm/openai.ts b/src/infra/llm/openai.ts index 558f224..9717f35 100644 --- a/src/infra/llm/openai.ts +++ b/src/infra/llm/openai.ts @@ -1,79 +1,117 @@ export { generateContentWithOpenAI }; -import { type Config, type OpenAITokens } from "@/domain/config/config"; +import OpenAI from "openai"; + +import { type Config, type OpenAITokens, type OpenAIEffort } from "@/domain/config/config"; import { type GenerateContentParams } from "@/domain/llm/router"; import { Future } from "@/libs/future"; import { getOpenAIAccessToken } from "@/infra/auth/openai"; import { extractResponse } from "@/domain/llm/response-parser"; - -import OpenAI from "openai"; +import { openaiReasoningParam } from "@/domain/llm/effort"; +import { tryWithEffort, type EffortAttempt } from "@/infra/llm/effort-fallback"; +import { type Maybe } from "@/libs/maybe"; type OpenAIConfig = Extract; const toError = (error: unknown): Error => (error instanceof Error ? error : new Error(String(error))); -const callOpenAIWithApiKey = (authToken: string, model: string, params: GenerateContentParams): Future => - Future.attemptP(async () => { - const client = new OpenAI({ apiKey: authToken }); - return await client.responses.create({ - model, - instructions: params.systemInstruction ?? null, - input: params.prompt - }); - }) - .mapRej(toError) - .chain((response) => extractResponse({ provider: "openai", source: "direct", value: response })); - -const callOpenAIWithOAuth = (authToken: string, model: string, params: GenerateContentParams): Future => - Future.attemptP(async () => { - const client = new OpenAI({ - baseURL: "https://chatgpt.com/backend-api/codex", - apiKey: authToken - }); - - const stream = client.responses.stream({ - model, - instructions: params.systemInstruction ?? "", - input: [{ role: "user", content: params.prompt }], - store: false - }); - - let deltaSnapshotText = ""; - let doneEventText = ""; - - stream.on("response.output_text.delta", (event) => { - deltaSnapshotText = event.snapshot; - }); - - stream.on("response.output_text.done", (event) => { - doneEventText = event.text; - }); - - const response = await stream.finalResponse(); - return { response, doneEventText, deltaSnapshotText }; - }) - .mapRej(toError) - .chain((bundle) => extractResponse({ provider: "openai", source: "stream", value: bundle })); +const callOpenAIWithApiKey = ( + authToken: string, + model: string, + effort: Maybe, + params: GenerateContentParams +): Future => { + const run = (withReasoning: boolean): Future => + Future.attemptP(async () => { + const client = new OpenAI({ apiKey: authToken }); + const reasoning = withReasoning ? openaiReasoningParam(effort) : undefined; + return await client.responses.create({ + model, + instructions: params.systemInstruction ?? null, + input: params.prompt, + // TODO: we need to avoid use this "{}" object; + ...(reasoning ?? {}) + }); + }) + .mapRej(toError) + .chain((response) => extractResponse({ provider: "openai", source: "direct", value: response })); + + // TODO: the implementation is not good if we need to do attempts to return some response. Remove this attempt and also think in a way to do this type-safe, no helpers and do the calls via SDK instead of REST, that way we can have better types and avoid all this "tryWithEffort" and "EffortAttempt" and "Maybe" and all that. We just need a simple function that tries to call the API with different parameters until it succeeds or runs out of options. + const attempts: readonly [EffortAttempt, ...EffortAttempt[]] = + openaiReasoningParam(effort) !== undefined ? [() => run(true), () => run(false)] : [() => run(false)]; + + return tryWithEffort(attempts); +}; + +const callOpenAIWithOAuth = ( + authToken: string, + model: string, + effort: Maybe, + params: GenerateContentParams +): Future => { + const run = (withReasoning: boolean): Future => + Future.attemptP(async () => { + const client = new OpenAI({ + baseURL: "https://chatgpt.com/backend-api/codex", + apiKey: authToken + }); + + const reasoning = withReasoning ? openaiReasoningParam(effort) : undefined; + + const stream = client.responses.stream({ + model, + instructions: params.systemInstruction ?? "", + input: [{ role: "user", content: params.prompt }], + store: false, + // TODO: we need to avoid use this "{}" object; + ...(reasoning ?? {}) + }); + + let deltaSnapshotText = ""; + let doneEventText = ""; + + stream.on("response.output_text.delta", (event) => { + deltaSnapshotText = event.snapshot; + }); + + stream.on("response.output_text.done", (event) => { + doneEventText = event.text; + }); + + const response = await stream.finalResponse(); + return { response, doneEventText, deltaSnapshotText }; + }) + .mapRej(toError) + .chain((bundle) => extractResponse({ provider: "openai", source: "stream", value: bundle })); + + // TODO: the implementation is not good if we need to do attempts to return some response. Remove this attempt and also think in a way to do this type-safe, no helpers and do the calls via SDK instead of REST, that way we can have better types and avoid all this "tryWithEffort" and "EffortAttempt" and "Maybe" and all that. We just need a simple function that tries to call the API with different parameters until it succeeds or runs out of options. + const attempts: readonly [EffortAttempt, ...EffortAttempt[]] = + openaiReasoningParam(effort) !== undefined ? [() => run(true), () => run(false)] : [() => run(false)]; + + return tryWithEffort(attempts); +}; const generateContentWithApiKey = ( apiKey: string, model: string, + effort: Maybe, params: GenerateContentParams -): Future => callOpenAIWithApiKey(apiKey, model, params); +): Future => callOpenAIWithApiKey(apiKey, model, effort, params); const generateContentWithOAuth = ( tokens: OpenAITokens, model: string, + effort: Maybe, params: GenerateContentParams ): Future => - getOpenAIAccessToken(tokens).chain((accessToken) => callOpenAIWithOAuth(accessToken, model, params)); + getOpenAIAccessToken(tokens).chain((accessToken) => callOpenAIWithOAuth(accessToken, model, effort, params)); const generateContentWithOpenAI = (config: OpenAIConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { case "api_key": - return generateContentWithApiKey(config.auth_method.content, config.model, params); + return generateContentWithApiKey(config.auth_method.content, config.model, config.effort, params); case "openai_oauth": - return generateContentWithOAuth(config.auth_method.content, config.model, params); + return generateContentWithOAuth(config.auth_method.content, config.model, config.effort, params); default: return Future.reject(new Error(`Unsupported auth method for OpenAI: ${config.auth_method.type}`)); } diff --git a/src/infra/storage/config.ts b/src/infra/storage/config.ts index fee888d..a09ab98 100644 --- a/src/infra/storage/config.ts +++ b/src/infra/storage/config.ts @@ -7,7 +7,6 @@ import { resolve } from "node:path"; import { homedir } from "node:os"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import { Config, type OAuthTokens, type OpenAITokens } from "@/domain/config/config"; -import { Just, Nothing, type Maybe } from "@/libs/maybe"; const CONFIG_DIR = resolve(homedir(), ".commit-tools"); const CONFIG_FILE = resolve(CONFIG_DIR, "config.json"); @@ -30,36 +29,36 @@ const saveConfig = (config: Config): Future => await writeFile(CONFIG_FILE, JSON.stringify(s.encode(Config, config), null, 2), "utf-8"); }); -const extractGoogleOAuthConfig = (config: Config): Maybe<{ model: string }> => - config.ai.provider === "gemini" && config.ai.auth_method.type === "google_oauth" ? - Just({ model: config.ai.model }) - : Nothing(); - -const extractOpenAIOAuthConfig = (config: Config): Maybe<{ model: string }> => - config.ai.provider === "openai" && config.ai.auth_method.type === "openai_oauth" ? - Just({ model: config.ai.model }) - : Nothing(); - const updateGoogleTokens = (tokens: OAuthTokens): Future => - loadConfig().chain((config) => - extractGoogleOAuthConfig(config).maybe( - Future.reject(new Error("Cannot update tokens: not using Google OAuth authentication")), - ({ model }) => - saveConfig({ - ...config, - ai: { provider: "gemini", model, auth_method: { type: "google_oauth", content: tokens } } - }) - ) - ); + loadConfig().chain((config) => { + // TODO: Why we are doing this? + if (config.ai.provider !== "gemini" || config.ai.auth_method.type !== "google_oauth") { + return Future.reject(new Error("Cannot update tokens: not using Google OAuth authentication")); + } + return saveConfig({ + ...config, + ai: { + provider: "gemini", + model: config.ai.model, + auth_method: { type: "google_oauth", content: tokens }, + effort: config.ai.effort + } + }); + }); const updateOpenAITokens = (tokens: OpenAITokens): Future => - loadConfig().chain((config) => - extractOpenAIOAuthConfig(config).maybe( - Future.reject(new Error("Cannot update tokens: not using OpenAI OAuth authentication")), - ({ model }) => - saveConfig({ - ...config, - ai: { provider: "openai", model, auth_method: { type: "openai_oauth", content: tokens } } - }) - ) - ); + loadConfig().chain((config) => { + // TODO: Why we are doing this? + if (config.ai.provider !== "openai" || config.ai.auth_method.type !== "openai_oauth") { + return Future.reject(new Error("Cannot update tokens: not using OpenAI OAuth authentication")); + } + return saveConfig({ + ...config, + ai: { + provider: "openai", + model: config.ai.model, + auth_method: { type: "openai_oauth", content: tokens }, + effort: config.ai.effort + } + }); + }); diff --git a/src/infra/ui/effort-picker.ts b/src/infra/ui/effort-picker.ts new file mode 100644 index 0000000..32a2221 --- /dev/null +++ b/src/infra/ui/effort-picker.ts @@ -0,0 +1,64 @@ +export { selectOpenAIEffort, selectAnthropicEffort, selectGeminiEffort }; + +import { ThinkingLevel } from "@google/genai"; + +import { Future } from "@/libs/future"; +import { Just, Nothing, type Maybe } from "@/libs/maybe"; +import { + OPENAI_EFFORTS, + ANTHROPIC_EFFORTS, + GEMINI_EFFORTS, + type OpenAIEffort, + type AnthropicEffort, + type GeminiEffort +} from "@/domain/config/config"; + +type EffortSliderModule = typeof import("@/infra/ui/effort-slider"); + +const selectEffort = ( + options: readonly V[], + modelId: string, + currentEffort: Maybe, + defaultValue: V +): Future> => { + const initialIndex = currentEffort.maybe(Math.max(0, options.indexOf(defaultValue)), (v) => { + const idx = options.indexOf(v); + return idx >= 0 ? idx : Math.max(0, options.indexOf(defaultValue)); + }); + + return Future.attemptP(async () => { + // TODO: Why this is imported here and not at the top of the file? + const { render } = await import("ink"); + const React = await import("react"); + const sliderModule: EffortSliderModule = await import("@/infra/ui/effort-slider"); + + return new Promise>((resolve, reject) => { + const { unmount } = render( + React.createElement(sliderModule.EffortSlider, { + title: `Reasoning effort for ${modelId}`, + options, + initialIndex, + onSubmit: (value: V) => { + unmount(); + resolve(Just(value)); + }, + onCancel: () => { + unmount(); + reject(new Error("Selection cancelled")); + } + }) + ); + }); + }).chainRej((err) => (err.message === "Selection cancelled" ? Future.resolve(Nothing()) : Future.reject(err))); +}; + +const selectOpenAIEffort = (modelId: string, current: Maybe): Future> => + selectEffort(OPENAI_EFFORTS, modelId, current, "medium"); + +const selectAnthropicEffort = ( + modelId: string, + current: Maybe +): Future> => selectEffort(ANTHROPIC_EFFORTS, modelId, current, "high"); + +const selectGeminiEffort = (modelId: string, current: Maybe): Future> => + selectEffort(GEMINI_EFFORTS, modelId, current, ThinkingLevel.HIGH); diff --git a/src/infra/ui/effort-slider.tsx b/src/infra/ui/effort-slider.tsx new file mode 100644 index 0000000..be3acdf --- /dev/null +++ b/src/infra/ui/effort-slider.tsx @@ -0,0 +1,120 @@ +export { EffortSlider, type EffortSliderProps }; + +import * as React from "react"; + +import { Box, Text, useInput, useApp, type Key } from "ink"; + +import chalk from "chalk"; + +type ChalkColor = "yellow" | "green" | "cyan" | "blueBright" | "magenta" | "red" | "gray"; + +type EffortSliderProps = { + title: string; + options: readonly V[]; + initialIndex: number; + onSubmit: (value: V) => void; + onCancel: () => void; +}; + +const PALETTE: Record = { + 2: ["yellow", "red"], + 3: ["yellow", "green", "red"], + 4: ["yellow", "green", "magenta", "red"], + 5: ["yellow", "green", "cyan", "magenta", "red"], + 6: ["yellow", "green", "cyan", "blueBright", "magenta", "red"] +}; + +const paletteFor = (n: number): readonly ChalkColor[] => PALETTE[n] ?? PALETTE[6]!; + +const colorize = (color: ChalkColor, text: string, bold: boolean): string => + bold ? chalk[color].bold(text) : chalk[color](text); + +const EffortSlider = ({ title, options, initialIndex, onSubmit, onCancel }: EffortSliderProps) => { + const { exit } = useApp(); + const clamped = Math.max(0, Math.min(options.length - 1, initialIndex)); + const [index, setIndex] = React.useState(clamped); + + const handleLifecycle = (key: Key): boolean => { + if (key.escape) { + onCancel(); + exit(); + return true; + } + if (key.return) { + onSubmit(options[index]!); + exit(); + return true; + } + return false; + }; + + const handleNavigation = (key: Key): boolean => { + if (key.leftArrow) { + setIndex((i) => Math.max(0, i - 1)); + return true; + } + if (key.rightArrow) { + setIndex((i) => Math.min(options.length - 1, i + 1)); + return true; + } + return false; + }; + + useInput((_input, key) => { + if (handleLifecycle(key)) return; + handleNavigation(key); + }); + + const cols = Math.min(72, Math.max(40, (process.stdout.columns ?? 72) - 8)); + const palette = paletteFor(options.length); + const lastIndex = options.length - 1; + const step = lastIndex > 0 ? Math.floor((cols - 1) / lastIndex) : 0; + const markerCol = index * step; + const markerColor = palette[index] ?? "cyan"; + + const railChars = Array.from({ length: cols }).map((_, c) => + c === markerCol ? chalk[markerColor]("▲") : chalk.dim("─") + ); + const rail = railChars.join(""); + + const labelParts: string[] = options.map((opt, i) => { + const color = palette[i] ?? "gray"; + return i === index ? colorize(color, opt, true) : chalk.gray(opt); + }); + const labelWidth = (cols - options.join("").length) / Math.max(1, options.length - 1); + const labels = labelParts.join(" ".repeat(Math.max(1, Math.floor(labelWidth)))); + + const spacerLen = Math.max(1, cols - "Speed".length - "Intelligence".length); + return ( + + + + {title} + + + + + + + Speed + {" ".repeat(spacerLen)} + Intelligence + + + + {rail} + + + + {labels} + + + + + + + Use ◀ ▶ to adjust • Enter to confirm • Esc to cancel + + + ); +}; diff --git a/src/infra/ui/push-note.ts b/src/infra/ui/push-note.ts index d7eb237..16183ad 100644 --- a/src/infra/ui/push-note.ts +++ b/src/infra/ui/push-note.ts @@ -8,10 +8,10 @@ import { Just, type Maybe } from "@/libs/maybe"; import { absurd } from "@/libs/types"; type PushMetadata = { - commit: CommitMetadata; - localBranch: string; - upstream: Maybe; - remoteUrl: string; + commit: Maybe; + localBranch: Maybe; + baseBranch: Maybe; + remoteUrl: Maybe; range: Maybe; pr: PrLookup; }; @@ -24,6 +24,7 @@ const renderPrLine = (lookup: PrLookup): string[] => { return [`pr #${lookup.pr.number} ${lookup.pr.url}`]; case "unauthenticated": return [`pr Tip: run 'gh auth login' to show open PR in the current branch...`]; + case "not-found": case "unavailable": return []; default: @@ -31,21 +32,32 @@ const renderPrLine = (lookup: PrLookup): string[] => { } }; -const renderPushNote = (m: PushMetadata): void => { - const branchLine = - m.upstream instanceof Just ? `branch ${m.localBranch} → ${m.upstream.value}` : `branch ${m.localBranch}`; +const renderCommitLines = (commit: Maybe): string[] => + // TODO: Why we check if it's a Just here and not in the caller? + commit instanceof Just ? + [ + `commit ${commit.value.short} ${commit.value.subject}`, + `author ${commit.value.authorName} <${commit.value.authorEmail}>`, + `date ${formatDate(commit.value.date)}` + ] + : []; +const renderPushNote = (m: PushMetadata): void => { + const branchLine = m.localBranch instanceof Just ? [`branch ${m.localBranch.value}`] : []; + const baseLine = m.baseBranch instanceof Just ? [`base ${m.baseBranch.value}`] : []; + const remoteLine = m.remoteUrl instanceof Just ? [`remote ${m.remoteUrl.value}`] : []; const rangeLine = m.range instanceof Just ? [`range ${m.range.value.before}..${m.range.value.after}`] : []; const body = [ - `commit ${m.commit.short} ${m.commit.subject}`, - `author ${m.commit.authorName} <${m.commit.authorEmail}>`, - `date ${formatDate(m.commit.date)}`, - branchLine, - `remote ${m.remoteUrl}`, + ...renderCommitLines(m.commit), + ...branchLine, + ...baseLine, + ...remoteLine, ...rangeLine, ...renderPrLine(m.pr) ].join("\n"); + if (!body) return; + p.note(body, "Pushed"); }; From 63a436c01c5c0a80d2defcaa56c00d78c8b6c431 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Thu, 23 Apr 2026 19:07:02 -0300 Subject: [PATCH 05/38] Add optional git metadata lookup helpers and simplify push rendering - Add `findCurrentBranch`, `findBaseBranch`, `findCommitMetadata`, and `findTrackingRemoteUrl` to return `Nothing` instead of failing on missing git metadata. - Update commit and doctor flows to use the new lookup helpers and remove duplicated local error recovery logic. - Let push note rendering consume `Maybe` values directly with `maybe(...)` instead of checking `Just` instances manually. - Remove fallback warning handling around push note metadata collection now that lookups recover gracefully. --- src/cli/commit.ts | 34 +++++----------------------------- src/cli/doctor.ts | 14 +++----------- src/infra/git/repo.ts | 22 ++++++++++++++++++++++ src/infra/ui/push-note.ts | 23 ++++++++++------------- 4 files changed, 40 insertions(+), 53 deletions(-) diff --git a/src/cli/commit.ts b/src/cli/commit.ts index 7485069..41ca884 100644 --- a/src/cli/commit.ts +++ b/src/cli/commit.ts @@ -89,25 +89,6 @@ class Commit { : publish ? "Published successfully!" : "Pushed successfully!"; - // TODO: We definetly need to remove these functions; If we need to do this complex thing, maybe the function signature needs to be changed to something more simple - // ====================================== // - const warnField = (label: string, err: Error): void => p.log.warn(color.yellow(`[${label}] ${err.message}`)); - - const optional = (label: string, f: Future): Future> => - f - .map((v): Maybe => Just(v)) - .chainRej((err): Future> => { - warnField(label, err); - return Future.resolve(Nothing()); - }); - - const recoverMaybe = (label: string, f: Future>): Future> => - f.chainRej((err): Future> => { - warnField(label, err); - return Future.resolve(Nothing()); - }); - // ====================================== // - return loading(startMsg, endMsg, repo.performPush(branch, publish, forceWithLease)).chain((result) => Future.concurrently< Error, @@ -119,17 +100,12 @@ class Commit { pr: pr.PrLookup; } >({ - commit: optional("commit", repo.getCommitMetadata()), - localBranch: optional("localBranch", repo.getCurrentBranch()), - baseBranch: recoverMaybe("baseBranch", repo.getBaseBranch()), - remoteUrl: optional("remoteUrl", repo.getTrackingRemoteUrl()), + commit: repo.findCommitMetadata(), + localBranch: repo.findCurrentBranch(), + baseBranch: repo.findBaseBranch(), + remoteUrl: repo.findTrackingRemoteUrl(), pr: pr.getOpenPullRequest() - }) - .map((parts) => renderPushNote({ ...parts, range: result.range })) - .chainRej((err) => { - p.log.warn(color.yellow(`Could not render commit metadata: ${err.message}`)); - return Future.resolve(undefined); - }) + }).map((parts) => renderPushNote({ ...parts, range: result.range })) ); } diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 8e62279..ed48373 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -6,7 +6,7 @@ import * as repo from "@/infra/git/repo"; import { Future } from "@/libs/future"; import { CONFIG_FILE, loadConfig } from "@/infra/storage/config"; import { type AuthMethod, type ProviderConfig } from "@/domain/config/config"; -import { Just, Nothing, type Maybe } from "@/libs/maybe"; +import { Just, type Maybe } from "@/libs/maybe"; import { absurd } from "@/libs/types"; import { access } from "node:fs/promises"; import { environment } from "@/infra/env"; @@ -110,17 +110,9 @@ class Doctor { } private collectGitRows(): Future { - // TODO: We definetly need to remove these functions; If we need to do this complex thing, maybe the function signature needs to be changed to something more simple - const optional = (f: Future): Future> => - f.map((v): Maybe => Just(v)).chainRej((): Future> => Future.resolve(Nothing())); - - // TODO: We definetly need to remove these functions; If we need to do this complex thing, maybe the function signature needs to be changed to something more simple - const recoverMaybe = (f: Future>): Future> => - f.chainRej((): Future> => Future.resolve(Nothing())); - return Future.concurrently; base: Maybe; pr: pr.PrLookup }>({ - branch: optional(repo.getCurrentBranch()), - base: recoverMaybe(repo.getBaseBranch()), + branch: repo.findCurrentBranch(), + base: repo.findBaseBranch(), pr: pr.getOpenPullRequest() }).map(({ branch, base, pr: prLookup }): CheckRow[] => [ renderBranchRow(branch), diff --git a/src/infra/git/repo.ts b/src/infra/git/repo.ts index 2bc5efe..b6d33e1 100644 --- a/src/infra/git/repo.ts +++ b/src/infra/git/repo.ts @@ -4,12 +4,16 @@ export { performCommit, performPush, getCurrentBranch, + findCurrentBranch, hasUpstream, getUpstream, getBaseBranch, + findBaseBranch, getCommitMetadata, + findCommitMetadata, getRemoteUrl, getTrackingRemoteUrl, + findTrackingRemoteUrl, type CommitMetadata, type PushResult, type PushRange @@ -108,6 +112,11 @@ const performPush = (branch?: string, publish = false, forceWithLease = false): const getCurrentBranch = (): Future => execGitChecked(["rev-parse", "--abbrev-ref", "HEAD"], "Failed to get current branch").map((s) => s.trim()); +const findCurrentBranch = (): Future> => + getCurrentBranch() + .map>((branch) => Just(branch)) + .chainRej(() => Future.resolve>(Nothing())); + const hasUpstream = (): Future => execBin("git", ["rev-parse", "--abbrev-ref", "@{u}"]).map(({ exitCode }) => exitCode === 0); @@ -165,6 +174,9 @@ const getBaseBranch = (): Future> => ) ); +const findBaseBranch = (): Future> => + getBaseBranch().chainRej(() => Future.resolve>(Nothing())); + const getRemoteUrl = (remote: string = "origin"): Future => execGitChecked(["remote", "get-url", remote], `Failed to read remote '${remote}' url`).map((s) => s.trim()); @@ -179,6 +191,11 @@ const getTrackingRemoteUrl = (): Future => return getRemoteUrl(remote instanceof Just ? remote.value : "origin"); }); +const findTrackingRemoteUrl = (): Future> => + getTrackingRemoteUrl() + .map>((url) => Just(url)) + .chainRej(() => Future.resolve>(Nothing())); + const getCommitMetadata = (ref: string = "HEAD"): Future => execGitChecked(["log", "-1", `--format=%H%n%h%n%s%n%an%n%ae%n%aI`, ref], "Failed to read commit metadata").chain( (stdout) => { @@ -189,3 +206,8 @@ const getCommitMetadata = (ref: string = "HEAD"): Future : Future.reject(new Error("Malformed git log output")); } ); + +const findCommitMetadata = (ref: string = "HEAD"): Future> => + getCommitMetadata(ref) + .map>((metadata) => Just(metadata)) + .chainRej(() => Future.resolve>(Nothing())); diff --git a/src/infra/ui/push-note.ts b/src/infra/ui/push-note.ts index 16183ad..75251a2 100644 --- a/src/infra/ui/push-note.ts +++ b/src/infra/ui/push-note.ts @@ -4,7 +4,7 @@ import * as p from "@clack/prompts"; import type { CommitMetadata, PushRange } from "@/infra/git/repo"; import type { PrLookup } from "@/infra/github/pr"; -import { Just, type Maybe } from "@/libs/maybe"; +import type { Maybe } from "@/libs/maybe"; import { absurd } from "@/libs/types"; type PushMetadata = { @@ -33,20 +33,17 @@ const renderPrLine = (lookup: PrLookup): string[] => { }; const renderCommitLines = (commit: Maybe): string[] => - // TODO: Why we check if it's a Just here and not in the caller? - commit instanceof Just ? - [ - `commit ${commit.value.short} ${commit.value.subject}`, - `author ${commit.value.authorName} <${commit.value.authorEmail}>`, - `date ${formatDate(commit.value.date)}` - ] - : []; + commit.maybe([], (value) => [ + `commit ${value.short} ${value.subject}`, + `author ${value.authorName} <${value.authorEmail}>`, + `date ${formatDate(value.date)}` + ]); const renderPushNote = (m: PushMetadata): void => { - const branchLine = m.localBranch instanceof Just ? [`branch ${m.localBranch.value}`] : []; - const baseLine = m.baseBranch instanceof Just ? [`base ${m.baseBranch.value}`] : []; - const remoteLine = m.remoteUrl instanceof Just ? [`remote ${m.remoteUrl.value}`] : []; - const rangeLine = m.range instanceof Just ? [`range ${m.range.value.before}..${m.range.value.after}`] : []; + const branchLine = m.localBranch.maybe([], (branch) => [`branch ${branch}`]); + const baseLine = m.baseBranch.maybe([], (base) => [`base ${base}`]); + const remoteLine = m.remoteUrl.maybe([], (url) => [`remote ${url}`]); + const rangeLine = m.range.maybe([], (range) => [`range ${range.before}..${range.after}`]); const body = [ ...renderCommitLines(m.commit), From 9b89a8ee93f8056fa4e639265ef7cc27f516d3b5 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Thu, 23 Apr 2026 19:56:35 -0300 Subject: [PATCH 06/38] Refactor `execBin` to return `Result`-based `ExecResult` - Change `ExecResult` to `Result` with explicit success and failure branches. - Introduce `CommandOutput` and `CommandFailure` types carrying stdout, stderr, and an `Error` with exit-code context. - Update all `execBin` callers in `repo.ts` and `pr.ts` to consume results via `result.either(...)` instead of inspecting `exitCode`. - Add `commandFailureMessage` helper to build error messages from stderr, stdout, or the underlying error. - Extract `formatCommitOutput` helper for post-commit stdout formatting. - Rename `reflog-cmd-failed.stderr` to `message` and use `commandFailureMessage` when constructing it. - Update `classifyFailure` in `pr.ts` to accept a `CommandFailure` and match patterns against combined failure text. - Wrap spawn `error` events with a clearer "Failed to start process" message. --- src/infra/git/repo.ts | 88 ++++++++++++++++++++++++------------------ src/infra/github/pr.ts | 18 ++++++--- src/infra/shell.ts | 24 ++++++++---- 3 files changed, 80 insertions(+), 50 deletions(-) diff --git a/src/infra/git/repo.ts b/src/infra/git/repo.ts index b6d33e1..3943af4 100644 --- a/src/infra/git/repo.ts +++ b/src/infra/git/repo.ts @@ -23,7 +23,7 @@ import { Future } from "@/libs/future"; import { Just, Nothing, type Maybe } from "@/libs/maybe"; import { type Result, Success, Failure } from "@/libs/result"; import { absurd } from "@/libs/types"; -import { execBin } from "@/infra/shell"; +import { execBin, type CommandFailure } from "@/infra/shell"; import { unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -47,17 +47,30 @@ type PushResult = { type BaseLookupError = | { type: "reflog-empty" } | { type: "reflog-not-creation"; subject: string } - | { type: "reflog-cmd-failed"; stderr: string }; + | { type: "reflog-cmd-failed"; message: string }; const CREATED_FROM_RE = /^branch: Created from (\S+)$/; +const commandFailureMessage = (failure: CommandFailure, fallbackMsg: string): string => + failure.output.stderr.trim() || failure.output.stdout.trim() || `${failure.error.message}: ${fallbackMsg}`; + const execGitChecked = (args: string[], fallbackMsg: string): Future => - execBin("git", args).chain(({ stdout, stderr, exitCode }) => - exitCode !== 0 ? - Future.reject(new Error(stderr.trim() || stdout.trim() || fallbackMsg)) - : Future.resolve(stdout) + execBin("git", args).chain((result) => + result.either( + (failure) => Future.reject(new Error(commandFailureMessage(failure, fallbackMsg))), + ({ stdout }) => Future.resolve(stdout) + ) ); +const formatCommitOutput = (stdout: string): string => + "\n" + + stdout + .split("\n") + .filter((line) => !line.startsWith("[")) + .join("\n") + .trim() + + "\n"; + const parsePushRange = (output: string): Maybe => { const m = output.match(/([0-9a-f]{7,40})\.\.([0-9a-f]{7,40})/); if (!m) return Nothing(); @@ -81,31 +94,26 @@ const performCommit = (message: string): Future => { Future.attemptP(() => writeFile(tmpPath, message, "utf-8")), () => Future.attemptP(() => unlink(tmpPath).catch(() => {})), () => execBin("git", ["commit", "-F", tmpPath]) - ).chain(({ stdout, stderr, exitCode }) => - exitCode !== 0 ? - Future.reject(new Error(stderr.trim() || stdout.trim() || "Commit failed")) - : Future.resolve( - "\n" + - stdout - .split("\n") - .filter((line) => !line.startsWith("[")) - .join("\n") - .trim() + - "\n" - ) + ).chain((result) => + result.either( + (failure) => Future.reject(new Error(commandFailureMessage(failure, "Commit failed"))), + ({ stdout }) => Future.resolve(formatCommitOutput(stdout)) + ) ); }; const performPush = (branch?: string, publish = false, forceWithLease = false): Future => { const args = publish && branch ? ["push", "--set-upstream", "origin", branch] : ["push"]; if (forceWithLease) args.push("--force-with-lease"); - return execBin("git", args).chain(({ stdout, stderr, exitCode }) => - exitCode !== 0 ? - Future.reject(new Error(stderr.trim() || stdout.trim() || "Push failed")) - : Future.resolve({ - output: stdout + stderr, - range: parsePushRange(stdout + "\n" + stderr) - }) + return execBin("git", args).chain((result) => + result.either( + (failure) => Future.reject(new Error(commandFailureMessage(failure, "Push failed"))), + ({ stdout, stderr }) => + Future.resolve({ + output: stdout + stderr, + range: parsePushRange(stdout + "\n" + stderr) + }) + ) ); }; @@ -118,11 +126,11 @@ const findCurrentBranch = (): Future> => .chainRej(() => Future.resolve>(Nothing())); const hasUpstream = (): Future => - execBin("git", ["rev-parse", "--abbrev-ref", "@{u}"]).map(({ exitCode }) => exitCode === 0); + execBin("git", ["rev-parse", "--abbrev-ref", "@{u}"]).map((result) => result.either(() => false, () => true)); const getUpstream = (): Future> => - execBin("git", ["rev-parse", "--abbrev-ref", "@{u}"]).map(({ stdout, exitCode }) => - exitCode !== 0 ? Nothing() : Just(stdout.trim()) + execBin("git", ["rev-parse", "--abbrev-ref", "@{u}"]).map((result) => + result.either(() => Nothing(), ({ stdout }) => Just(stdout.trim())) ); const oldestReflogSubject = (stdout: string): Result => { @@ -141,17 +149,23 @@ const parseBaseFromReflog = (stdout: string): Result => oldestReflogSubject(stdout).chain(parseCreatedFrom).map(normalizeBranchRef); const getBaseFromReflog = (branch: string): Future> => - execBin("git", ["log", "-g", "--format=%gs", branch]).map(({ stdout, stderr, exitCode }) => - // TODO: exitCode !== 0; why we need this? is there something we could move to handle inside some custom helper function like execGitChecked? - exitCode !== 0 ? - Failure({ type: "reflog-cmd-failed", stderr: stderr.trim() }) - : parseBaseFromReflog(stdout) + execBin("git", ["log", "-g", "--format=%gs", branch]).map((result) => + result.either( + (failure) => + Failure({ + type: "reflog-cmd-failed", + message: commandFailureMessage(failure, "Failed to read branch reflog") + }), + ({ stdout }) => parseBaseFromReflog(stdout) + ) ); const getDefaultRemoteBranch = (): Future> => - execBin("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]).map(({ stdout, exitCode }) => - // TODO: The same - exitCode !== 0 ? Nothing() : Just(stdout.trim().replace(/^origin\//, "")) + execBin("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]).map((result) => + result.either( + () => Nothing(), + ({ stdout }) => Just(stdout.trim().replace(/^origin\//, "")) + ) ); const getBaseBranch = (): Future> => @@ -164,7 +178,7 @@ const getBaseBranch = (): Future> => case "reflog-not-creation": return getDefaultRemoteBranch(); case "reflog-cmd-failed": - return Future.reject>(new Error(`git reflog failed: ${err.stderr}`)); + return Future.reject>(new Error(`git reflog failed: ${err.message}`)); default: return absurd(err, "BaseLookupError"); } diff --git a/src/infra/github/pr.ts b/src/infra/github/pr.ts index ca30725..4cf0386 100644 --- a/src/infra/github/pr.ts +++ b/src/infra/github/pr.ts @@ -5,7 +5,7 @@ import * as Decoder from "@/libs/json/decoder"; import { Future } from "@/libs/future"; import { Just, Nothing, type Maybe } from "@/libs/maybe"; -import { execBin } from "@/infra/shell"; +import { execBin, type CommandFailure } from "@/infra/shell"; type PullRequest = { url: string; number: number }; @@ -36,18 +36,24 @@ const parsePrJson = (stdout: string): PrLookup => (pr): PrLookup => ({ type: "found", pr }) ); -const classifyFailure = (stderr: string): PrLookup => - GH_UNAUTH_RE.test(stderr) ? { type: "unauthenticated" } - : GH_NOT_FOUND_RE.test(stderr) ? { type: "not-found" } +const commandFailureText = (failure: CommandFailure): string => + failure.output.stderr.trim() || failure.output.stdout.trim() || failure.error.message; + +const classifyFailure = (failure: CommandFailure): PrLookup => { + return GH_UNAUTH_RE.test(commandFailureText(failure)) + ? { type: "unauthenticated" } + : GH_NOT_FOUND_RE.test(commandFailureText(failure)) + ? { type: "not-found" } : { type: "unavailable" }; +}; const getOpenPullRequest = (): Future => Future.both(repo.getTrackingRemoteUrl(), repo.getCurrentBranch()) .chain(([remoteUrl, branch]) => { const slug = parseGithubRepo(remoteUrl); return slug instanceof Just ? - execBin("gh", ["pr", "view", branch, "-R", slug.value, "--json", "url,number"]).map( - ({ stdout, stderr, exitCode }) => (exitCode !== 0 ? classifyFailure(stderr) : parsePrJson(stdout)) + execBin("gh", ["pr", "view", branch, "-R", slug.value, "--json", "url,number"]).map((result) => + result.either(classifyFailure, ({ stdout }) => parsePrJson(stdout)) ) : Future.resolve({ type: "unavailable" }); }) diff --git a/src/infra/shell.ts b/src/infra/shell.ts index e4e71dd..0b11a04 100644 --- a/src/infra/shell.ts +++ b/src/infra/shell.ts @@ -1,20 +1,30 @@ -export { execBin, type ExecResult }; +export { execBin, type CommandOutput, type CommandFailure, type ExecResult }; import { Future } from "@/libs/future"; +import { Failure, type Result, Success } from "@/libs/result"; import { spawn } from "node:child_process"; -type ExecResult = { stdout: string; stderr: string; exitCode: number }; +type CommandOutput = Readonly<{ stdout: string; stderr: string }>; +type CommandFailure = Readonly<{ output: CommandOutput; error: Error }>; +type ExecResult = Result; + +const commandResult = (output: CommandOutput, exitCode: number | null, signal: NodeJS.Signals | null): ExecResult => + exitCode === 0 ? + Success(output) + : Failure({ output, error: new Error(`Command failed with exit code ${exitCode}${signal ? ` and signal ${signal}` : ""}`) }); const execBin = (bin: string, args: string[]): Future => Future.create((reject, resolve) => { const proc = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; let stderr = ""; + proc.stdout.on("data", (d: Buffer) => (stdout += d.toString())); proc.stderr.on("data", (d: Buffer) => (stderr += d.toString())); - proc.on("error", (err) => reject(err instanceof Error ? err : new Error(String(err)))); - proc.on("close", (exitCode) => resolve({ stdout, stderr, exitCode: exitCode ?? 1 })); - return () => { - proc.kill(); - }; + + proc.on("error", (err) => reject(new Error(`Failed to start process: ${err.message}`))); + proc.on("close", (exitCode, signal) => resolve(commandResult({ stdout, stderr }, exitCode, signal))); + + return () => proc.kill(); }); From 150f74a4192db871975d6fa9f6d8b06ef290f897 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Fri, 24 Apr 2026 09:53:06 -0300 Subject: [PATCH 07/38] Remove immutable snapshots guidance from conventions --- CONVENTIONS.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/CONVENTIONS.md b/CONVENTIONS.md index c025112..3d9d87a 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -58,14 +58,6 @@ Favour static types, explicit data flow, immutability, pure functions, compositi s.variant({ type: "error", message: s.string, code: s.optional(s.string) }) ]); ``` -- Embed **immutable snapshots** in event payloads when referencing mutable external data (e.g. a property's price at the time of booking). - ```ts - const schema_UserActions = s.discriminatedUnion([ - s.variant({ type: "view_property", property: schema_PropertySnapshot }), - s.variant({ type: "start_booking", property: schema_PropertySnapshot, draftId: DraftId.schema }), - s.variant({ type: "confirm_booking", draftId: DraftId.schema }) - ]); - ``` - Bundle related state into **union-driven state machines**. Don't use loose boolean flags (`isStreaming`, `isError`, `isLoading`) spread across stores. ```ts From dab95ec7f71595720f128bf4d3a46a3af96f4f51 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Fri, 24 Apr 2026 09:55:41 -0300 Subject: [PATCH 08/38] Simplify effort value declarations in config --- src/domain/config/config.ts | 39 +++---------------------------------- 1 file changed, 3 insertions(+), 36 deletions(-) diff --git a/src/domain/config/config.ts b/src/domain/config/config.ts index dcea6f2..d71e737 100644 --- a/src/domain/config/config.ts +++ b/src/domain/config/config.ts @@ -71,47 +71,14 @@ const schema_AuthMethod = s.discriminatedUnion([ ]); type AuthMethod = s.Infer["type"]; -// TODO: omg, what is that? -// Effort value arrays — the one runtime listing the UI and schema need. -// -// Provider notes on "why the hand-written list": -// - OpenAI's `ReasoningEffort` and Anthropic's `OutputConfig.effort` are both -// TypeScript string-union types. TS types don't exist at runtime, so we -// CAN'T iterate them — the values must be enumerated. Safety comes from -// two complementary compile-time checks: -// 1. `satisfies readonly NonNullable[]` → each element is valid. -// 2. `assertExhaustive<...>()` → SDK has no extra value. -// If a pinned SDK adds or removes a value, the build breaks immediately. -// - Gemini's `ThinkingLevel` IS a real (runtime) enum, so we derive the -// array directly via `Object.values`. No hand-written list at all. -const OPENAI_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const satisfies readonly NonNullable< - OpenAIPkg.Reasoning["effort"] ->[]; -const ANTHROPIC_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const satisfies readonly NonNullable< - AnthropicPkg.OutputConfig["effort"] ->[]; - -const isNamedThinkingLevel = ( - v: ThinkingLevel -): v is Exclude => - v !== ThinkingLevel.THINKING_LEVEL_UNSPECIFIED; - -const GEMINI_EFFORTS = Object.values(ThinkingLevel).filter(isNamedThinkingLevel); +const OPENAI_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const satisfies readonly NonNullable[]; +const ANTHROPIC_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const satisfies readonly NonNullable[]; +const GEMINI_EFFORTS = [ThinkingLevel.MINIMAL, ThinkingLevel.LOW, ThinkingLevel.MEDIUM, ThinkingLevel.HIGH] as const satisfies readonly ThinkingLevel[]; type OpenAIEffort = (typeof OPENAI_EFFORTS)[number]; type AnthropicEffort = (typeof ANTHROPIC_EFFORTS)[number]; type GeminiEffort = (typeof GEMINI_EFFORTS)[number]; -// Reverse-direction guard: every SDK-declared value must appear in our array. -// `satisfies` catches "array has invalid value"; this catches "SDK added a -// value we haven't listed yet". Together they lock the two sides together. -type AssertEmpty = [T] extends [never] ? true : ["Missing SDK effort values", T]; -const _openaiCoversSdk: AssertEmpty, OpenAIEffort>> = true; -const _anthropicCoversSdk: AssertEmpty, AnthropicEffort>> = - true; -void _openaiCoversSdk; -void _anthropicCoversSdk; - const schema_ProviderConfig = s.discriminatedUnion([ s.variant({ provider: "gemini", From 1c8123f96d186bc8759f628740bc6e353007d2c3 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Fri, 24 Apr 2026 09:55:58 -0300 Subject: [PATCH 09/38] Increase Prettier `printWidth` to 154 and reformat codebase - Bump `printWidth` from 120 to 154 in `.prettierrc` and update the `CONVENTIONS.md` example accordingly. - Reflow `pnpm-lock.yaml` `resolution` entries onto single lines under the new width. - Collapse multi-line function signatures, conditionals, and expressions across `src/` to fit the wider line limit. --- .prettierrc | 2 +- CONVENTIONS.md | 6 +- pnpm-lock.yaml | 1020 ++++++++++-------------------- src/cli/commit.ts | 16 +- src/cli/doctor.ts | 19 +- src/cli/model.ts | 10 +- src/cli/parser.ts | 11 +- src/cli/setup.ts | 9 +- src/domain/commit/models.ts | 13 +- src/domain/commit/prompts.ts | 4 +- src/domain/llm/auth-resolver.ts | 6 +- src/domain/llm/effort.ts | 24 +- src/domain/llm/router.ts | 8 +- src/infra/auth/google.ts | 55 +- src/infra/auth/openai.ts | 39 +- src/infra/git/repo.ts | 43 +- src/infra/github/pr.ts | 19 +- src/infra/llm/anthropic.ts | 16 +- src/infra/llm/effort-fallback.ts | 3 +- src/infra/llm/gemini.ts | 27 +- src/infra/llm/openai.ts | 25 +- src/infra/shell.ts | 9 +- src/infra/ui/effort-picker.ts | 22 +- src/infra/ui/effort-slider.tsx | 7 +- src/infra/ui/push-note.ts | 9 +- src/libs/future.ts | 5 +- src/libs/helpers/object.ts | 15 +- src/libs/json/decoder.ts | 23 +- src/libs/json/encoder.ts | 3 +- src/libs/json/schema.ts | 22 +- src/libs/maybe.ts | 12 +- src/libs/remote-data.ts | 8 +- src/libs/router.ts | 38 +- src/libs/time.ts | 5 +- src/libs/trampoline.ts | 4 +- src/libs/types.ts | 3 +- 36 files changed, 478 insertions(+), 1082 deletions(-) diff --git a/.prettierrc b/.prettierrc index 0ccdaff..dca7881 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { - "printWidth": 120, + "printWidth": 154, "proseWrap": "preserve", "semi": true, "singleQuote": false, diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 3d9d87a..f4e2644 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -61,11 +61,7 @@ Favour static types, explicit data flow, immutability, pure functions, compositi - Bundle related state into **union-driven state machines**. Don't use loose boolean flags (`isStreaming`, `isError`, `isLoading`) spread across stores. ```ts - type Stream = - | { type: "not_started" } - | { type: "streaming"; results: R[] } - | { type: "done"; results: R[] } - | { type: "error"; error: E }; + type Stream = { type: "not_started" } | { type: "streaming"; results: R[] } | { type: "done"; results: R[] } | { type: "error"; error: E }; type VoiceConnection = | { type: "disconnected" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd52b3e..0b6c32c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,13 +92,11 @@ importers: packages: "@alcalzone/ansi-tokenize@0.2.5": - resolution: - { integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw== } + resolution: { integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw== } engines: { node: ">=18" } "@anthropic-ai/sdk@0.90.0": - resolution: - { integrity: sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg== } + resolution: { integrity: sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg== } hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -107,255 +105,215 @@ packages: optional: true "@babel/runtime@7.29.2": - resolution: - { integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g== } + resolution: { integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g== } engines: { node: ">=6.9.0" } "@clack/core@1.1.0": - resolution: - { integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA== } + resolution: { integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA== } "@clack/prompts@1.1.0": - resolution: - { integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g== } + resolution: { integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g== } "@colors/colors@1.5.0": - resolution: - { integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== } + resolution: { integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== } engines: { node: ">=0.1.90" } "@esbuild/aix-ppc64@0.27.4": - resolution: - { integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q== } + resolution: { integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q== } engines: { node: ">=18" } cpu: [ppc64] os: [aix] "@esbuild/android-arm64@0.27.4": - resolution: - { integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw== } + resolution: { integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw== } engines: { node: ">=18" } cpu: [arm64] os: [android] "@esbuild/android-arm@0.27.4": - resolution: - { integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ== } + resolution: { integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ== } engines: { node: ">=18" } cpu: [arm] os: [android] "@esbuild/android-x64@0.27.4": - resolution: - { integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw== } + resolution: { integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw== } engines: { node: ">=18" } cpu: [x64] os: [android] "@esbuild/darwin-arm64@0.27.4": - resolution: - { integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ== } + resolution: { integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ== } engines: { node: ">=18" } cpu: [arm64] os: [darwin] "@esbuild/darwin-x64@0.27.4": - resolution: - { integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw== } + resolution: { integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw== } engines: { node: ">=18" } cpu: [x64] os: [darwin] "@esbuild/freebsd-arm64@0.27.4": - resolution: - { integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw== } + resolution: { integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw== } engines: { node: ">=18" } cpu: [arm64] os: [freebsd] "@esbuild/freebsd-x64@0.27.4": - resolution: - { integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ== } + resolution: { integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ== } engines: { node: ">=18" } cpu: [x64] os: [freebsd] "@esbuild/linux-arm64@0.27.4": - resolution: - { integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA== } + resolution: { integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA== } engines: { node: ">=18" } cpu: [arm64] os: [linux] "@esbuild/linux-arm@0.27.4": - resolution: - { integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg== } + resolution: { integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg== } engines: { node: ">=18" } cpu: [arm] os: [linux] "@esbuild/linux-ia32@0.27.4": - resolution: - { integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA== } + resolution: { integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA== } engines: { node: ">=18" } cpu: [ia32] os: [linux] "@esbuild/linux-loong64@0.27.4": - resolution: - { integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA== } + resolution: { integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA== } engines: { node: ">=18" } cpu: [loong64] os: [linux] "@esbuild/linux-mips64el@0.27.4": - resolution: - { integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw== } + resolution: { integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw== } engines: { node: ">=18" } cpu: [mips64el] os: [linux] "@esbuild/linux-ppc64@0.27.4": - resolution: - { integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA== } + resolution: { integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA== } engines: { node: ">=18" } cpu: [ppc64] os: [linux] "@esbuild/linux-riscv64@0.27.4": - resolution: - { integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw== } + resolution: { integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw== } engines: { node: ">=18" } cpu: [riscv64] os: [linux] "@esbuild/linux-s390x@0.27.4": - resolution: - { integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA== } + resolution: { integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA== } engines: { node: ">=18" } cpu: [s390x] os: [linux] "@esbuild/linux-x64@0.27.4": - resolution: - { integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA== } + resolution: { integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA== } engines: { node: ">=18" } cpu: [x64] os: [linux] "@esbuild/netbsd-arm64@0.27.4": - resolution: - { integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q== } + resolution: { integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q== } engines: { node: ">=18" } cpu: [arm64] os: [netbsd] "@esbuild/netbsd-x64@0.27.4": - resolution: - { integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg== } + resolution: { integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg== } engines: { node: ">=18" } cpu: [x64] os: [netbsd] "@esbuild/openbsd-arm64@0.27.4": - resolution: - { integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow== } + resolution: { integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow== } engines: { node: ">=18" } cpu: [arm64] os: [openbsd] "@esbuild/openbsd-x64@0.27.4": - resolution: - { integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ== } + resolution: { integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ== } engines: { node: ">=18" } cpu: [x64] os: [openbsd] "@esbuild/openharmony-arm64@0.27.4": - resolution: - { integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg== } + resolution: { integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg== } engines: { node: ">=18" } cpu: [arm64] os: [openharmony] "@esbuild/sunos-x64@0.27.4": - resolution: - { integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g== } + resolution: { integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g== } engines: { node: ">=18" } cpu: [x64] os: [sunos] "@esbuild/win32-arm64@0.27.4": - resolution: - { integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg== } + resolution: { integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg== } engines: { node: ">=18" } cpu: [arm64] os: [win32] "@esbuild/win32-ia32@0.27.4": - resolution: - { integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw== } + resolution: { integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw== } engines: { node: ">=18" } cpu: [ia32] os: [win32] "@esbuild/win32-x64@0.27.4": - resolution: - { integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg== } + resolution: { integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg== } engines: { node: ">=18" } cpu: [x64] os: [win32] "@eslint-community/eslint-utils@4.9.1": - resolution: - { integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== } + resolution: { integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== } engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 "@eslint-community/regexpp@4.12.2": - resolution: - { integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== } + resolution: { integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== } engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } "@eslint/config-array@0.21.2": - resolution: - { integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw== } + resolution: { integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } "@eslint/config-helpers@0.4.2": - resolution: - { integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw== } + resolution: { integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } "@eslint/core@0.17.0": - resolution: - { integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ== } + resolution: { integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } "@eslint/eslintrc@3.3.5": - resolution: - { integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg== } + resolution: { integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } "@eslint/js@9.39.4": - resolution: - { integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw== } + resolution: { integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } "@eslint/object-schema@2.1.7": - resolution: - { integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA== } + resolution: { integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } "@eslint/plugin-kit@0.4.1": - resolution: - { integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA== } + resolution: { integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } "@google/genai@1.50.1": - resolution: - { integrity: sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ== } + resolution: { integrity: sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ== } engines: { node: ">=20.0.0" } peerDependencies: "@modelcontextprotocol/sdk": ^1.25.2 @@ -364,325 +322,262 @@ packages: optional: true "@humanfs/core@0.19.2": - resolution: - { integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA== } + resolution: { integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA== } engines: { node: ">=18.18.0" } "@humanfs/node@0.16.8": - resolution: - { integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ== } + resolution: { integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ== } engines: { node: ">=18.18.0" } "@humanfs/types@0.15.0": - resolution: - { integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q== } + resolution: { integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q== } engines: { node: ">=18.18.0" } "@humanwhocodes/module-importer@1.0.1": - resolution: - { integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== } + resolution: { integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== } engines: { node: ">=12.22" } "@humanwhocodes/retry@0.4.3": - resolution: - { integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== } + resolution: { integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== } engines: { node: ">=18.18" } "@jridgewell/gen-mapping@0.3.13": - resolution: - { integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== } + resolution: { integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== } "@jridgewell/resolve-uri@3.1.2": - resolution: - { integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== } + resolution: { integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== } engines: { node: ">=6.0.0" } "@jridgewell/sourcemap-codec@1.5.5": - resolution: - { integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== } + resolution: { integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== } "@jridgewell/trace-mapping@0.3.31": - resolution: - { integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== } + resolution: { integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== } "@protobufjs/aspromise@1.1.2": - resolution: - { integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== } + resolution: { integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== } "@protobufjs/base64@1.1.2": - resolution: - { integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== } + resolution: { integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== } "@protobufjs/codegen@2.0.4": - resolution: - { integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== } + resolution: { integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== } "@protobufjs/eventemitter@1.1.0": - resolution: - { integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== } + resolution: { integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== } "@protobufjs/fetch@1.1.0": - resolution: - { integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== } + resolution: { integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== } "@protobufjs/float@1.0.2": - resolution: - { integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== } + resolution: { integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== } "@protobufjs/inquire@1.1.0": - resolution: - { integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== } + resolution: { integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== } "@protobufjs/path@1.1.2": - resolution: - { integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== } + resolution: { integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== } "@protobufjs/pool@1.1.0": - resolution: - { integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== } + resolution: { integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== } "@protobufjs/utf8@1.1.0": - resolution: - { integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== } + resolution: { integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== } "@rollup/rollup-android-arm-eabi@4.59.0": - resolution: - { integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg== } + resolution: { integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg== } cpu: [arm] os: [android] "@rollup/rollup-android-arm64@4.59.0": - resolution: - { integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q== } + resolution: { integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q== } cpu: [arm64] os: [android] "@rollup/rollup-darwin-arm64@4.59.0": - resolution: - { integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg== } + resolution: { integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg== } cpu: [arm64] os: [darwin] "@rollup/rollup-darwin-x64@4.59.0": - resolution: - { integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w== } + resolution: { integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w== } cpu: [x64] os: [darwin] "@rollup/rollup-freebsd-arm64@4.59.0": - resolution: - { integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA== } + resolution: { integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA== } cpu: [arm64] os: [freebsd] "@rollup/rollup-freebsd-x64@4.59.0": - resolution: - { integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg== } + resolution: { integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg== } cpu: [x64] os: [freebsd] "@rollup/rollup-linux-arm-gnueabihf@4.59.0": - resolution: - { integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw== } + resolution: { integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw== } cpu: [arm] os: [linux] libc: [glibc] "@rollup/rollup-linux-arm-musleabihf@4.59.0": - resolution: - { integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA== } + resolution: { integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA== } cpu: [arm] os: [linux] libc: [musl] "@rollup/rollup-linux-arm64-gnu@4.59.0": - resolution: - { integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA== } + resolution: { integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA== } cpu: [arm64] os: [linux] libc: [glibc] "@rollup/rollup-linux-arm64-musl@4.59.0": - resolution: - { integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA== } + resolution: { integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA== } cpu: [arm64] os: [linux] libc: [musl] "@rollup/rollup-linux-loong64-gnu@4.59.0": - resolution: - { integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg== } + resolution: { integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg== } cpu: [loong64] os: [linux] libc: [glibc] "@rollup/rollup-linux-loong64-musl@4.59.0": - resolution: - { integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q== } + resolution: { integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q== } cpu: [loong64] os: [linux] libc: [musl] "@rollup/rollup-linux-ppc64-gnu@4.59.0": - resolution: - { integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA== } + resolution: { integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA== } cpu: [ppc64] os: [linux] libc: [glibc] "@rollup/rollup-linux-ppc64-musl@4.59.0": - resolution: - { integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA== } + resolution: { integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA== } cpu: [ppc64] os: [linux] libc: [musl] "@rollup/rollup-linux-riscv64-gnu@4.59.0": - resolution: - { integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg== } + resolution: { integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg== } cpu: [riscv64] os: [linux] libc: [glibc] "@rollup/rollup-linux-riscv64-musl@4.59.0": - resolution: - { integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg== } + resolution: { integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg== } cpu: [riscv64] os: [linux] libc: [musl] "@rollup/rollup-linux-s390x-gnu@4.59.0": - resolution: - { integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w== } + resolution: { integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w== } cpu: [s390x] os: [linux] libc: [glibc] "@rollup/rollup-linux-x64-gnu@4.59.0": - resolution: - { integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg== } + resolution: { integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg== } cpu: [x64] os: [linux] libc: [glibc] "@rollup/rollup-linux-x64-musl@4.59.0": - resolution: - { integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg== } + resolution: { integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg== } cpu: [x64] os: [linux] libc: [musl] "@rollup/rollup-openbsd-x64@4.59.0": - resolution: - { integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ== } + resolution: { integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ== } cpu: [x64] os: [openbsd] "@rollup/rollup-openharmony-arm64@4.59.0": - resolution: - { integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA== } + resolution: { integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA== } cpu: [arm64] os: [openharmony] "@rollup/rollup-win32-arm64-msvc@4.59.0": - resolution: - { integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A== } + resolution: { integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A== } cpu: [arm64] os: [win32] "@rollup/rollup-win32-ia32-msvc@4.59.0": - resolution: - { integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA== } + resolution: { integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA== } cpu: [ia32] os: [win32] "@rollup/rollup-win32-x64-gnu@4.59.0": - resolution: - { integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA== } + resolution: { integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA== } cpu: [x64] os: [win32] "@rollup/rollup-win32-x64-msvc@4.59.0": - resolution: - { integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA== } + resolution: { integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA== } cpu: [x64] os: [win32] "@types/body-parser@1.19.6": - resolution: - { integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g== } + resolution: { integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g== } "@types/connect@3.4.38": - resolution: - { integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== } + resolution: { integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== } "@types/estree@1.0.8": - resolution: - { integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== } + resolution: { integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== } "@types/express-serve-static-core@5.1.1": - resolution: - { integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A== } + resolution: { integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A== } "@types/express@5.0.6": - resolution: - { integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA== } + resolution: { integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA== } "@types/http-errors@2.0.5": - resolution: - { integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg== } + resolution: { integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg== } "@types/ink-text-input@2.0.5": - resolution: - { integrity: sha512-dp6qIrf/VKQyiJfLSOTk7XhbAG3SkGLMrL/5QwaOpYae20+/+VFLuEgpfjpAWOITVL7uyiE9nKdQ4pk03T2F+Q== } + resolution: { integrity: sha512-dp6qIrf/VKQyiJfLSOTk7XhbAG3SkGLMrL/5QwaOpYae20+/+VFLuEgpfjpAWOITVL7uyiE9nKdQ4pk03T2F+Q== } "@types/ink@0.5.2": - resolution: - { integrity: sha512-yEuhWTRMXJkIWiaM58c5kuRot5HFccv9kjjgy0fBZG0+tYb+sMBaxHNPVqyZXspGGlmwDOQ1d3BsygWpWlW17w== } + resolution: { integrity: sha512-yEuhWTRMXJkIWiaM58c5kuRot5HFccv9kjjgy0fBZG0+tYb+sMBaxHNPVqyZXspGGlmwDOQ1d3BsygWpWlW17w== } "@types/json-schema@7.0.15": - resolution: - { integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== } + resolution: { integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== } "@types/luxon@3.7.1": - resolution: - { integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg== } + resolution: { integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg== } "@types/node@22.19.15": - resolution: - { integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg== } + resolution: { integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg== } "@types/prop-types@15.7.15": - resolution: - { integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== } + resolution: { integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== } "@types/qs@6.15.0": - resolution: - { integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow== } + resolution: { integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow== } "@types/range-parser@1.2.7": - resolution: - { integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== } + resolution: { integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== } "@types/react@19.2.14": - resolution: - { integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w== } + resolution: { integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w== } "@types/retry@0.12.0": - resolution: - { integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== } + resolution: { integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== } "@types/send@1.2.1": - resolution: - { integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ== } + resolution: { integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ== } "@types/serve-static@2.2.0": - resolution: - { integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ== } + resolution: { integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ== } "@typescript-eslint/eslint-plugin@8.59.0": - resolution: - { integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw== } + resolution: { integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: "@typescript-eslint/parser": ^8.59.0 @@ -690,275 +585,222 @@ packages: typescript: ">=4.8.4 <6.1.0" "@typescript-eslint/parser@8.59.0": - resolution: - { integrity: sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg== } + resolution: { integrity: sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" "@typescript-eslint/project-service@8.59.0": - resolution: - { integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw== } + resolution: { integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: typescript: ">=4.8.4 <6.1.0" "@typescript-eslint/scope-manager@8.59.0": - resolution: - { integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg== } + resolution: { integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } "@typescript-eslint/tsconfig-utils@8.59.0": - resolution: - { integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg== } + resolution: { integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: typescript: ">=4.8.4 <6.1.0" "@typescript-eslint/type-utils@8.59.0": - resolution: - { integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg== } + resolution: { integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" "@typescript-eslint/types@8.59.0": - resolution: - { integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A== } + resolution: { integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } "@typescript-eslint/typescript-estree@8.59.0": - resolution: - { integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw== } + resolution: { integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: typescript: ">=4.8.4 <6.1.0" "@typescript-eslint/utils@8.59.0": - resolution: - { integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g== } + resolution: { integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" "@typescript-eslint/visitor-keys@8.59.0": - resolution: - { integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q== } + resolution: { integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } acorn-jsx@5.3.2: - resolution: - { integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== } + resolution: { integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== } peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 acorn@8.16.0: - resolution: - { integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== } + resolution: { integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== } engines: { node: ">=0.4.0" } hasBin: true agent-base@7.1.4: - resolution: - { integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== } + resolution: { integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== } engines: { node: ">= 14" } ajv@6.14.0: - resolution: - { integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== } + resolution: { integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== } ansi-escapes@7.3.0: - resolution: - { integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg== } + resolution: { integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg== } engines: { node: ">=18" } ansi-regex@5.0.1: - resolution: - { integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== } + resolution: { integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== } engines: { node: ">=8" } ansi-regex@6.2.2: - resolution: - { integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== } + resolution: { integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== } engines: { node: ">=12" } ansi-styles@4.3.0: - resolution: - { integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== } + resolution: { integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== } engines: { node: ">=8" } ansi-styles@6.2.3: - resolution: - { integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== } + resolution: { integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== } engines: { node: ">=12" } any-promise@1.3.0: - resolution: - { integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== } + resolution: { integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== } argparse@2.0.1: - resolution: - { integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== } + resolution: { integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== } auto-bind@5.0.1: - resolution: - { integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg== } + resolution: { integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg== } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } balanced-match@1.0.2: - resolution: - { integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== } + resolution: { integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== } balanced-match@4.0.4: - resolution: - { integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== } + resolution: { integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== } engines: { node: 18 || 20 || >=22 } base64-js@1.5.1: - resolution: - { integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== } + resolution: { integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== } bignumber.js@9.3.1: - resolution: - { integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ== } + resolution: { integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ== } brace-expansion@1.1.14: - resolution: - { integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g== } + resolution: { integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g== } brace-expansion@5.0.5: - resolution: - { integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== } + resolution: { integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== } engines: { node: 18 || 20 || >=22 } buffer-equal-constant-time@1.0.1: - resolution: - { integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== } + resolution: { integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== } builtin-modules@3.3.0: - resolution: - { integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== } + resolution: { integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== } engines: { node: ">=6" } bundle-name@4.1.0: - resolution: - { integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q== } + resolution: { integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q== } engines: { node: ">=18" } bundle-require@5.1.0: - resolution: - { integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA== } + resolution: { integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA== } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } peerDependencies: esbuild: ">=0.18" bytes@3.1.2: - resolution: - { integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== } + resolution: { integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== } engines: { node: ">= 0.8" } cac@6.7.14: - resolution: - { integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== } + resolution: { integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== } engines: { node: ">=8" } callsites@3.1.0: - resolution: - { integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== } + resolution: { integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== } engines: { node: ">=6" } chalk@4.1.2: - resolution: - { integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== } + resolution: { integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== } engines: { node: ">=10" } chalk@5.6.2: - resolution: - { integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== } + resolution: { integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== } engines: { node: ^12.17.0 || ^14.13 || >=16.0.0 } chokidar@4.0.3: - resolution: - { integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== } + resolution: { integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== } engines: { node: ">= 14.16.0" } cli-boxes@3.0.0: - resolution: - { integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g== } + resolution: { integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g== } engines: { node: ">=10" } cli-cursor@4.0.0: - resolution: - { integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg== } + resolution: { integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg== } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } cli-table3@0.6.5: - resolution: - { integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ== } + resolution: { integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ== } engines: { node: 10.* || >= 12.* } cli-truncate@5.2.0: - resolution: - { integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw== } + resolution: { integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw== } engines: { node: ">=20" } code-excerpt@4.0.0: - resolution: - { integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA== } + resolution: { integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA== } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } color-convert@2.0.1: - resolution: - { integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== } + resolution: { integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== } engines: { node: ">=7.0.0" } color-name@1.1.4: - resolution: - { integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== } + resolution: { integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== } commander@4.1.1: - resolution: - { integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== } + resolution: { integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== } engines: { node: ">= 6" } concat-map@0.0.1: - resolution: - { integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== } + resolution: { integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== } confbox@0.1.8: - resolution: - { integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w== } + resolution: { integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w== } consola@3.4.2: - resolution: - { integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA== } + resolution: { integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA== } engines: { node: ^14.18.0 || >=16.10.0 } convert-to-spaces@2.0.1: - resolution: - { integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ== } + resolution: { integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ== } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } cross-spawn@7.0.6: - resolution: - { integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== } + resolution: { integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== } engines: { node: ">= 8" } csstype@3.2.3: - resolution: - { integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== } + resolution: { integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== } data-uri-to-buffer@4.0.1: - resolution: - { integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== } + resolution: { integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== } engines: { node: ">= 12" } debug@4.4.3: - resolution: - { integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== } + resolution: { integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== } engines: { node: ">=6.0" } peerDependencies: supports-color: "*" @@ -967,90 +809,72 @@ packages: optional: true deep-is@0.1.4: - resolution: - { integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== } + resolution: { integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== } default-browser-id@5.0.1: - resolution: - { integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q== } + resolution: { integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q== } engines: { node: ">=18" } default-browser@5.5.0: - resolution: - { integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw== } + resolution: { integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw== } engines: { node: ">=18" } define-lazy-prop@3.0.0: - resolution: - { integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg== } + resolution: { integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg== } engines: { node: ">=12" } ecdsa-sig-formatter@1.0.11: - resolution: - { integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== } + resolution: { integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== } emoji-regex@10.6.0: - resolution: - { integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A== } + resolution: { integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A== } emoji-regex@8.0.0: - resolution: - { integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== } + resolution: { integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== } environment@1.1.0: - resolution: - { integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q== } + resolution: { integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q== } engines: { node: ">=18" } es-toolkit@1.45.1: - resolution: - { integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw== } + resolution: { integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw== } esbuild@0.27.4: - resolution: - { integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ== } + resolution: { integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ== } engines: { node: ">=18" } hasBin: true escape-string-regexp@2.0.0: - resolution: - { integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== } + resolution: { integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== } engines: { node: ">=8" } escape-string-regexp@4.0.0: - resolution: - { integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== } + resolution: { integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== } engines: { node: ">=10" } eslint-plugin-sonarjs@4.0.3: - resolution: - { integrity: sha512-5drkJKLC9qQddIiaATV0e8+ygbUc7b0Ti6VB7M2d3jmKNh3X0RaiIJYTs3dr9xnlhlrxo+/s1FoO3Jgv6O/c7g== } + resolution: { integrity: sha512-5drkJKLC9qQddIiaATV0e8+ygbUc7b0Ti6VB7M2d3jmKNh3X0RaiIJYTs3dr9xnlhlrxo+/s1FoO3Jgv6O/c7g== } peerDependencies: eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 eslint-scope@8.4.0: - resolution: - { integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== } + resolution: { integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } eslint-visitor-keys@3.4.3: - resolution: - { integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== } + resolution: { integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== } engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } eslint-visitor-keys@4.2.1: - resolution: - { integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== } + resolution: { integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } eslint-visitor-keys@5.0.1: - resolution: - { integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== } + resolution: { integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== } engines: { node: ^20.19.0 || ^22.13.0 || >=24 } eslint@9.39.4: - resolution: - { integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ== } + resolution: { integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } hasBin: true peerDependencies: @@ -1060,49 +884,39 @@ packages: optional: true espree@10.4.0: - resolution: - { integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== } + resolution: { integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } esquery@1.7.0: - resolution: - { integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== } + resolution: { integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== } engines: { node: ">=0.10" } esrecurse@4.3.0: - resolution: - { integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== } + resolution: { integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== } engines: { node: ">=4.0" } estraverse@5.3.0: - resolution: - { integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== } + resolution: { integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== } engines: { node: ">=4.0" } esutils@2.0.3: - resolution: - { integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== } + resolution: { integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== } engines: { node: ">=0.10.0" } extend@3.0.2: - resolution: - { integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== } + resolution: { integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== } fast-deep-equal@3.1.3: - resolution: - { integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== } + resolution: { integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== } fast-json-stable-stringify@2.1.0: - resolution: - { integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== } + resolution: { integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== } fast-levenshtein@2.0.6: - resolution: - { integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== } + resolution: { integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== } fdir@6.5.0: - resolution: - { integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== } + resolution: { integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== } engines: { node: ">=12.0.0" } peerDependencies: picomatch: ^3 || ^4 @@ -1111,135 +925,108 @@ packages: optional: true fetch-blob@3.2.0: - resolution: - { integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== } + resolution: { integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== } engines: { node: ^12.20 || >= 14.13 } file-entry-cache@8.0.0: - resolution: - { integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== } + resolution: { integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== } engines: { node: ">=16.0.0" } find-up@5.0.0: - resolution: - { integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== } + resolution: { integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== } engines: { node: ">=10" } fix-dts-default-cjs-exports@1.0.1: - resolution: - { integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg== } + resolution: { integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg== } flat-cache@4.0.1: - resolution: - { integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== } + resolution: { integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== } engines: { node: ">=16" } flatted@3.4.2: - resolution: - { integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== } + resolution: { integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== } fluture@14.0.0: - resolution: - { integrity: sha512-pENtLF948a8DfduVKugT8edTAbFi4rBS94xjHwzLanQqIu5PYtLGl+xqs6H8TaIRL7z/B0cDpswdINzH/HRUGA== } + resolution: { integrity: sha512-pENtLF948a8DfduVKugT8edTAbFi4rBS94xjHwzLanQqIu5PYtLGl+xqs6H8TaIRL7z/B0cDpswdINzH/HRUGA== } engines: { node: ">=4.0.0" } formdata-polyfill@4.0.10: - resolution: - { integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== } + resolution: { integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== } engines: { node: ">=12.20.0" } fsevents@2.3.3: - resolution: - { integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== } + resolution: { integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== } engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } os: [darwin] functional-red-black-tree@1.0.1: - resolution: - { integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g== } + resolution: { integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g== } gaxios@7.1.4: - resolution: - { integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA== } + resolution: { integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA== } engines: { node: ">=18" } gcp-metadata@8.1.2: - resolution: - { integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg== } + resolution: { integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg== } engines: { node: ">=18" } get-east-asian-width@1.5.0: - resolution: - { integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA== } + resolution: { integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA== } engines: { node: ">=18" } get-tsconfig@4.13.6: - resolution: - { integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw== } + resolution: { integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw== } glob-parent@6.0.2: - resolution: - { integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== } + resolution: { integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== } engines: { node: ">=10.13.0" } globals@14.0.0: - resolution: - { integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== } + resolution: { integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== } engines: { node: ">=18" } globals@17.5.0: - resolution: - { integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g== } + resolution: { integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g== } engines: { node: ">=18" } google-auth-library@10.6.2: - resolution: - { integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw== } + resolution: { integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw== } engines: { node: ">=18" } google-logging-utils@1.1.3: - resolution: - { integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA== } + resolution: { integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA== } engines: { node: ">=14" } has-flag@4.0.0: - resolution: - { integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== } + resolution: { integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== } engines: { node: ">=8" } https-proxy-agent@7.0.6: - resolution: - { integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== } + resolution: { integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== } engines: { node: ">= 14" } ignore@5.3.2: - resolution: - { integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== } + resolution: { integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== } engines: { node: ">= 4" } ignore@7.0.5: - resolution: - { integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== } + resolution: { integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== } engines: { node: ">= 4" } import-fresh@3.3.1: - resolution: - { integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== } + resolution: { integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== } engines: { node: ">=6" } imurmurhash@0.1.4: - resolution: - { integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== } + resolution: { integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== } engines: { node: ">=0.8.19" } indent-string@5.0.0: - resolution: - { integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg== } + resolution: { integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg== } engines: { node: ">=12" } ink@6.8.0: - resolution: - { integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA== } + resolution: { integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA== } engines: { node: ">=20" } peerDependencies: "@types/react": ">=19.0.0" @@ -1252,205 +1039,162 @@ packages: optional: true is-docker@3.0.0: - resolution: - { integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ== } + resolution: { integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ== } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } hasBin: true is-extglob@2.1.1: - resolution: - { integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== } + resolution: { integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== } engines: { node: ">=0.10.0" } is-fullwidth-code-point@3.0.0: - resolution: - { integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== } + resolution: { integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== } engines: { node: ">=8" } is-fullwidth-code-point@5.1.0: - resolution: - { integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ== } + resolution: { integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ== } engines: { node: ">=18" } is-glob@4.0.3: - resolution: - { integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== } + resolution: { integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== } engines: { node: ">=0.10.0" } is-in-ci@2.0.0: - resolution: - { integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w== } + resolution: { integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w== } engines: { node: ">=20" } hasBin: true is-in-ssh@1.0.0: - resolution: - { integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw== } + resolution: { integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw== } engines: { node: ">=20" } is-inside-container@1.0.0: - resolution: - { integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA== } + resolution: { integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA== } engines: { node: ">=14.16" } hasBin: true is-wsl@3.1.1: - resolution: - { integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw== } + resolution: { integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw== } engines: { node: ">=16" } isexe@2.0.0: - resolution: - { integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== } + resolution: { integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== } joycon@3.1.1: - resolution: - { integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== } + resolution: { integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== } engines: { node: ">=10" } js-yaml@4.1.1: - resolution: - { integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== } + resolution: { integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== } hasBin: true json-bigint@1.0.0: - resolution: - { integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ== } + resolution: { integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ== } json-buffer@3.0.1: - resolution: - { integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== } + resolution: { integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== } json-schema-to-ts@3.1.1: - resolution: - { integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g== } + resolution: { integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g== } engines: { node: ">=16" } json-schema-traverse@0.4.1: - resolution: - { integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== } + resolution: { integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== } json-stable-stringify-without-jsonify@1.0.1: - resolution: - { integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== } + resolution: { integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== } jsx-ast-utils-x@0.1.0: - resolution: - { integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw== } + resolution: { integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } jwa@2.0.1: - resolution: - { integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== } + resolution: { integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== } jws@4.0.1: - resolution: - { integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== } + resolution: { integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== } keyv@4.5.4: - resolution: - { integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== } + resolution: { integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== } levn@0.4.1: - resolution: - { integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== } + resolution: { integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== } engines: { node: ">= 0.8.0" } lilconfig@3.1.3: - resolution: - { integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw== } + resolution: { integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw== } engines: { node: ">=14" } lines-and-columns@1.2.4: - resolution: - { integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== } + resolution: { integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== } load-tsconfig@0.2.5: - resolution: - { integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg== } + resolution: { integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg== } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } locate-path@6.0.0: - resolution: - { integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== } + resolution: { integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== } engines: { node: ">=10" } lodash.merge@4.6.2: - resolution: - { integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== } + resolution: { integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== } long@5.3.2: - resolution: - { integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== } + resolution: { integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== } luxon@3.7.2: - resolution: - { integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew== } + resolution: { integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew== } engines: { node: ">=12" } magic-string@0.30.21: - resolution: - { integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== } + resolution: { integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== } mimic-fn@2.1.0: - resolution: - { integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== } + resolution: { integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== } engines: { node: ">=6" } minimatch@10.2.5: - resolution: - { integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== } + resolution: { integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== } engines: { node: 18 || 20 || >=22 } minimatch@3.1.5: - resolution: - { integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== } + resolution: { integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== } mlly@1.8.1: - resolution: - { integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ== } + resolution: { integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ== } ms@2.1.3: - resolution: - { integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== } + resolution: { integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== } mz@2.7.0: - resolution: - { integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== } + resolution: { integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== } natural-compare@1.4.0: - resolution: - { integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== } + resolution: { integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== } node-domexception@1.0.0: - resolution: - { integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== } + resolution: { integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== } engines: { node: ">=10.5.0" } deprecated: Use your platform's native DOMException instead node-fetch@3.3.2: - resolution: - { integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA== } + resolution: { integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA== } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } object-assign@4.1.1: - resolution: - { integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== } + resolution: { integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== } engines: { node: ">=0.10.0" } onetime@5.1.2: - resolution: - { integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== } + resolution: { integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== } engines: { node: ">=6" } open@11.0.0: - resolution: - { integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw== } + resolution: { integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw== } engines: { node: ">=20" } openai@6.34.0: - resolution: - { integrity: sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw== } + resolution: { integrity: sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw== } hasBin: true peerDependencies: ws: ^8.18.0 @@ -1462,70 +1206,56 @@ packages: optional: true optionator@0.9.4: - resolution: - { integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== } + resolution: { integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== } engines: { node: ">= 0.8.0" } p-limit@3.1.0: - resolution: - { integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== } + resolution: { integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== } engines: { node: ">=10" } p-locate@5.0.0: - resolution: - { integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== } + resolution: { integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== } engines: { node: ">=10" } p-retry@4.6.2: - resolution: - { integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== } + resolution: { integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== } engines: { node: ">=8" } parent-module@1.0.1: - resolution: - { integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== } + resolution: { integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== } engines: { node: ">=6" } patch-console@2.0.0: - resolution: - { integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA== } + resolution: { integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA== } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } path-exists@4.0.0: - resolution: - { integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== } + resolution: { integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== } engines: { node: ">=8" } path-key@3.1.1: - resolution: - { integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== } + resolution: { integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== } engines: { node: ">=8" } pathe@2.0.3: - resolution: - { integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== } + resolution: { integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== } picocolors@1.1.1: - resolution: - { integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== } + resolution: { integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== } picomatch@4.0.3: - resolution: - { integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== } + resolution: { integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== } engines: { node: ">=12" } pirates@4.0.7: - resolution: - { integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== } + resolution: { integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== } engines: { node: ">= 6" } pkg-types@1.3.1: - resolution: - { integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ== } + resolution: { integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ== } postcss-load-config@6.0.1: - resolution: - { integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g== } + resolution: { integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g== } engines: { node: ">= 18" } peerDependencies: jiti: ">=1.21.0" @@ -1543,18 +1273,15 @@ packages: optional: true powershell-utils@0.1.0: - resolution: - { integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A== } + resolution: { integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A== } engines: { node: ">=20" } prelude-ls@1.2.1: - resolution: - { integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== } + resolution: { integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== } engines: { node: ">= 0.8.0" } prettier-plugin-tailwindcss@0.7.2: - resolution: - { integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA== } + resolution: { integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA== } engines: { node: ">=20.19" } peerDependencies: "@ianvs/prettier-plugin-sort-imports": "*" @@ -1609,244 +1336,195 @@ packages: optional: true prettier@3.8.1: - resolution: - { integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== } + resolution: { integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== } engines: { node: ">=14" } hasBin: true protobufjs@7.5.5: - resolution: - { integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg== } + resolution: { integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg== } engines: { node: ">=12.0.0" } punycode@2.3.1: - resolution: - { integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== } + resolution: { integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== } engines: { node: ">=6" } react-devtools-core@7.0.1: - resolution: - { integrity: sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw== } + resolution: { integrity: sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw== } react-reconciler@0.33.0: - resolution: - { integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA== } + resolution: { integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA== } engines: { node: ">=0.10.0" } peerDependencies: react: ^19.2.0 react@19.2.4: - resolution: - { integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== } + resolution: { integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== } engines: { node: ">=0.10.0" } readdirp@4.1.2: - resolution: - { integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== } + resolution: { integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== } engines: { node: ">= 14.18.0" } refa@0.12.1: - resolution: - { integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g== } + resolution: { integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g== } engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } regexp-ast-analysis@0.7.1: - resolution: - { integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A== } + resolution: { integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A== } engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } resolve-from@4.0.0: - resolution: - { integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== } + resolution: { integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== } engines: { node: ">=4" } resolve-from@5.0.0: - resolution: - { integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== } + resolution: { integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== } engines: { node: ">=8" } resolve-pkg-maps@1.0.0: - resolution: - { integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== } + resolution: { integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== } restore-cursor@4.0.0: - resolution: - { integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg== } + resolution: { integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg== } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } retry@0.13.1: - resolution: - { integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== } + resolution: { integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== } engines: { node: ">= 4" } rollup@4.59.0: - resolution: - { integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg== } + resolution: { integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg== } engines: { node: ">=18.0.0", npm: ">=8.0.0" } hasBin: true run-applescript@7.1.0: - resolution: - { integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q== } + resolution: { integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q== } engines: { node: ">=18" } safe-buffer@5.2.1: - resolution: - { integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== } + resolution: { integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== } sanctuary-show@2.0.0: - resolution: - { integrity: sha512-REj4ZiioUXnDLj6EpJ9HcYDIEGaEexmB9Fg5o6InZR9f0x5PfnnC21QeU9SZ9E7G8zXSZPNjy8VRUK4safbesw== } + resolution: { integrity: sha512-REj4ZiioUXnDLj6EpJ9HcYDIEGaEexmB9Fg5o6InZR9f0x5PfnnC21QeU9SZ9E7G8zXSZPNjy8VRUK4safbesw== } sanctuary-type-identifiers@3.0.0: - resolution: - { integrity: sha512-YFXYcG0Ura1dSPd/1xLYtE2XAWUEsBHhMTZvYBOvwT8MeFQwdUOCMm2DC+r94z6H93FVq0qxDac8/D7QpJj6Mg== } + resolution: { integrity: sha512-YFXYcG0Ura1dSPd/1xLYtE2XAWUEsBHhMTZvYBOvwT8MeFQwdUOCMm2DC+r94z6H93FVq0qxDac8/D7QpJj6Mg== } scheduler@0.27.0: - resolution: - { integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== } + resolution: { integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== } scslre@0.3.0: - resolution: - { integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ== } + resolution: { integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ== } engines: { node: ^14.0.0 || >=16.0.0 } semver@7.7.4: - resolution: - { integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== } + resolution: { integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== } engines: { node: ">=10" } hasBin: true shebang-command@2.0.0: - resolution: - { integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== } + resolution: { integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== } engines: { node: ">=8" } shebang-regex@3.0.0: - resolution: - { integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== } + resolution: { integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== } engines: { node: ">=8" } shell-quote@1.8.3: - resolution: - { integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw== } + resolution: { integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw== } engines: { node: ">= 0.4" } signal-exit@3.0.7: - resolution: - { integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== } + resolution: { integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== } sisteransi@1.0.5: - resolution: - { integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== } + resolution: { integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== } slice-ansi@8.0.0: - resolution: - { integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg== } + resolution: { integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg== } engines: { node: ">=20" } source-map@0.7.6: - resolution: - { integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== } + resolution: { integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== } engines: { node: ">= 12" } stack-utils@2.0.6: - resolution: - { integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== } + resolution: { integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== } engines: { node: ">=10" } string-width@4.2.3: - resolution: - { integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== } + resolution: { integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== } engines: { node: ">=8" } string-width@7.2.0: - resolution: - { integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== } + resolution: { integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== } engines: { node: ">=18" } string-width@8.2.0: - resolution: - { integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw== } + resolution: { integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw== } engines: { node: ">=20" } strip-ansi@6.0.1: - resolution: - { integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== } + resolution: { integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== } engines: { node: ">=8" } strip-ansi@7.2.0: - resolution: - { integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== } + resolution: { integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== } engines: { node: ">=12" } strip-json-comments@3.1.1: - resolution: - { integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== } + resolution: { integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== } engines: { node: ">=8" } sucrase@3.35.1: - resolution: - { integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw== } + resolution: { integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw== } engines: { node: ">=16 || 14 >=14.17" } hasBin: true supports-color@7.2.0: - resolution: - { integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== } + resolution: { integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== } engines: { node: ">=8" } tagged-tag@1.0.0: - resolution: - { integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng== } + resolution: { integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng== } engines: { node: ">=20" } terminal-size@4.0.1: - resolution: - { integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ== } + resolution: { integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ== } engines: { node: ">=18" } thenify-all@1.6.0: - resolution: - { integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA== } + resolution: { integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA== } engines: { node: ">=0.8" } thenify@3.3.1: - resolution: - { integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== } + resolution: { integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== } tinyexec@0.3.2: - resolution: - { integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== } + resolution: { integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== } tinyglobby@0.2.15: - resolution: - { integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== } + resolution: { integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== } engines: { node: ">=12.0.0" } tree-kill@1.2.2: - resolution: - { integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== } + resolution: { integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== } hasBin: true ts-algebra@2.0.0: - resolution: - { integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw== } + resolution: { integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw== } ts-api-utils@2.5.0: - resolution: - { integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== } + resolution: { integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== } engines: { node: ">=18.12" } peerDependencies: typescript: ">=4.8.4" ts-interface-checker@0.1.13: - resolution: - { integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== } + resolution: { integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== } tsup@8.5.1: - resolution: - { integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing== } + resolution: { integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing== } engines: { node: ">=18" } hasBin: true peerDependencies: @@ -1865,76 +1543,62 @@ packages: optional: true tsx@4.21.0: - resolution: - { integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw== } + resolution: { integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw== } engines: { node: ">=18.0.0" } hasBin: true type-check@0.4.0: - resolution: - { integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== } + resolution: { integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== } engines: { node: ">= 0.8.0" } type-fest@5.4.4: - resolution: - { integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw== } + resolution: { integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw== } engines: { node: ">=20" } typescript-eslint@8.59.0: - resolution: - { integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw== } + resolution: { integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" typescript@5.9.3: - resolution: - { integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== } + resolution: { integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== } engines: { node: ">=14.17" } hasBin: true ufo@1.6.3: - resolution: - { integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q== } + resolution: { integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q== } undici-types@6.21.0: - resolution: - { integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== } + resolution: { integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== } uri-js@4.4.1: - resolution: - { integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== } + resolution: { integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== } web-streams-polyfill@3.3.3: - resolution: - { integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== } + resolution: { integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== } engines: { node: ">= 8" } which@2.0.2: - resolution: - { integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== } + resolution: { integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== } engines: { node: ">= 8" } hasBin: true widest-line@6.0.0: - resolution: - { integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA== } + resolution: { integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA== } engines: { node: ">=20" } word-wrap@1.2.5: - resolution: - { integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== } + resolution: { integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== } engines: { node: ">=0.10.0" } wrap-ansi@9.0.2: - resolution: - { integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww== } + resolution: { integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww== } engines: { node: ">=18" } ws@7.5.10: - resolution: - { integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== } + resolution: { integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== } engines: { node: ">=8.3.0" } peerDependencies: bufferutil: ^4.0.1 @@ -1946,8 +1610,7 @@ packages: optional: true ws@8.19.0: - resolution: - { integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== } + resolution: { integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== } engines: { node: ">=10.0.0" } peerDependencies: bufferutil: ^4.0.1 @@ -1959,18 +1622,15 @@ packages: optional: true wsl-utils@0.3.1: - resolution: - { integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg== } + resolution: { integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg== } engines: { node: ">=20" } yocto-queue@0.1.0: - resolution: - { integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== } + resolution: { integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== } engines: { node: ">=10" } yoga-layout@3.2.1: - resolution: - { integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ== } + resolution: { integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ== } snapshots: "@alcalzone/ansi-tokenize@0.2.5": diff --git a/src/cli/commit.ts b/src/cli/commit.ts index 41ca884..6cdbdca 100644 --- a/src/cli/commit.ts +++ b/src/cli/commit.ts @@ -45,11 +45,7 @@ class Commit { return repo .checkIsGitRepo() .chain(() => this.diff()) - .chain((diff) => - this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((message) => - this.interact(diff, message) - ) - ) + .chain((diff) => this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((message) => this.interact(diff, message))) .mapRej((e) => { if (e instanceof Error) { p.log.error(color.red(e.message)); @@ -63,11 +59,7 @@ class Commit { } generate(diff: string, convention: CommitConvention, template: Maybe = Nothing()): Future { - return loading( - "Generating commit message...", - "Message generated!", - generateCommitMessage(this.providerConfig, diff, convention, template) - ); + return loading("Generating commit message...", "Message generated!", generateCommitMessage(this.providerConfig, diff, convention, template)); } refine(message: string, adjustment: string, diff: string): Future { @@ -117,9 +109,7 @@ class Commit { case "commit_push": return this.handleCommitAndPush(message); case "regenerate": - return this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((msg) => - this.interact(diff, msg) - ); + return this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((msg) => this.interact(diff, msg)); case "adjust": return this.handleAdjust(diff, message); case "cancel": diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index ed48373..5bf9539 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -103,10 +103,7 @@ class Doctor { return repo .checkIsGitRepo() .chain((): Future => this.collectGitRows()) - .chainRej( - (): Future => - Future.resolve([["Git Repository", color.yellow("Outside"), "Not a git repository"]]) - ); + .chainRej((): Future => Future.resolve([["Git Repository", color.yellow("Outside"), "Not a git repository"]])); } private collectGitRows(): Future { @@ -114,11 +111,7 @@ class Doctor { branch: repo.findCurrentBranch(), base: repo.findBaseBranch(), pr: pr.getOpenPullRequest() - }).map(({ branch, base, pr: prLookup }): CheckRow[] => [ - renderBranchRow(branch), - renderBaseRow(base), - renderPrRow(prLookup) - ]); + }).map(({ branch, base, pr: prLookup }): CheckRow[] => [renderBranchRow(branch), renderBaseRow(base), renderPrRow(prLookup)]); } private renderTable(rows: CheckRow[]): void { @@ -143,15 +136,11 @@ class Doctor { } function renderBranchRow(branch: Maybe): CheckRow { - return branch instanceof Just ? - ["Branch", color.green("Current"), branch.value] - : ["Branch", color.yellow("Unknown"), "Could not read current branch"]; + return branch instanceof Just ? ["Branch", color.green("Current"), branch.value] : ["Branch", color.yellow("Unknown"), "Could not read current branch"]; } function renderBaseRow(base: Maybe): CheckRow { - return base instanceof Just ? - ["Base", color.green("Detected"), base.value] - : ["Base", color.yellow("Unknown"), "Could not resolve base branch"]; + return base instanceof Just ? ["Base", color.green("Detected"), base.value] : ["Base", color.yellow("Unknown"), "Could not resolve base branch"]; } function renderPrRow(lookup: pr.PrLookup): CheckRow { diff --git a/src/cli/model.ts b/src/cli/model.ts index 784ac39..b98fcf5 100644 --- a/src/cli/model.ts +++ b/src/cli/model.ts @@ -21,20 +21,14 @@ class ModelCommand { static create(): Future { return loadConfig() - .chainRej(() => - Future.reject(new Error("No configuration found. Run 'commit-tools setup' first.")) - ) + .chainRej(() => Future.reject(new Error("No configuration found. Run 'commit-tools setup' first."))) .chain((config) => resolveProvider(config).map((ai) => new ModelCommand(config, ai))); } run(): Future { p.intro(color.bgCyan(color.black(" Change Model "))); - return loading( - "Fetching available models...", - "Models fetched!", - fetchModels(this.providerConfig.provider, this.providerConfig.auth_method) - ) + return loading("Fetching available models...", "Models fetched!", fetchModels(this.providerConfig.provider, this.providerConfig.auth_method)) .chain((models) => selectModelInteractively(models)) .chain((modelId) => selectEffortForProvider(withModel(this.config.ai, modelId))) .chain((ai) => saveConfig({ ...this.config, ai })) diff --git a/src/cli/parser.ts b/src/cli/parser.ts index 1325a9b..8ff044d 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -4,13 +4,7 @@ import * as D from "@/libs/json/decoder"; import { Result } from "@/libs/result"; -type CliCommand = - | { type: "generate" } - | { type: "setup" } - | { type: "doctor" } - | { type: "model" } - | { type: "version" } - | { type: "help" }; +type CliCommand = { type: "generate" } | { type: "setup" } | { type: "doctor" } | { type: "model" } | { type: "version" } | { type: "help" }; const cliCommandDecoder: D.Decoder = D.array(D.string).chain((args) => { const cmd = args[0] || "generate"; @@ -36,8 +30,7 @@ const cliCommandDecoder: D.Decoder = D.array(D.string).chain((args) } }); -const parseArgs = (args: string[]): Result => - D.decode(args, cliCommandDecoder).mapFailure((err) => new Error(err)); +const parseArgs = (args: string[]): Result => D.decode(args, cliCommandDecoder).mapFailure((err) => new Error(err)); const showHelp = (): void => { console.log(` diff --git a/src/cli/setup.ts b/src/cli/setup.ts index dbd6c2f..d5229e4 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -152,11 +152,7 @@ class Setup { } private finalizeSetup(authMethod: ProviderConfig["auth_method"]): Future { - return loading( - "Fetching available models...", - "Models fetched!", - fetchModels(this.preferences.provider, authMethod) - ) + return loading("Fetching available models...", "Models fetched!", fetchModels(this.preferences.provider, authMethod)) .chain((models) => selectModelInteractively(models)) .chain((modelId) => selectEffortForProvider(seedProviderConfig(this.preferences.provider, modelId, authMethod))) .chain((ai) => saveConfig(this.buildConfig(ai))) @@ -230,8 +226,7 @@ type ApiKeyPrompt = { readonly validate: (value: string | undefined) => string | undefined; }; -const genericApiKeyValidator = (value: string | undefined): string | undefined => - !value || value.length < 10 ? "API Key is too short" : undefined; +const genericApiKeyValidator = (value: string | undefined): string | undefined => (!value || value.length < 10 ? "API Key is too short" : undefined); function apiKeyPromptFor(provider: ProviderConfig["provider"]): ApiKeyPrompt { switch (provider) { diff --git a/src/domain/commit/models.ts b/src/domain/commit/models.ts index c396680..74673bc 100644 --- a/src/domain/commit/models.ts +++ b/src/domain/commit/models.ts @@ -41,9 +41,7 @@ const fetchOpenAIModelsWithOAuth = (tokens: ProviderConfig["auth_method"]["conte } const data = (await response.json()) as { models: CodexModel[] }; - return data.models - .sort((a, b) => a.slug.localeCompare(b.slug)) - .map((m) => ({ id: m.slug, description: m.description })); + return data.models.sort((a, b) => a.slug.localeCompare(b.slug)).map((m) => ({ id: m.slug, description: m.description })); }) ); @@ -112,15 +110,10 @@ const fetchAnthropicModels = (authMethod: ProviderConfig["auth_method"]): Future data?: Array<{ id: string; display_name?: string }>; }; - return (data.data ?? []) - .sort((a, b) => a.id.localeCompare(b.id)) - .map((m) => ({ id: m.id, description: m.display_name ?? "" })); + return (data.data ?? []).sort((a, b) => a.id.localeCompare(b.id)).map((m) => ({ id: m.id, description: m.display_name ?? "" })); }); -const fetchModels = ( - provider: ProviderConfig["provider"], - authMethod: ProviderConfig["auth_method"] -): Future => { +const fetchModels = (provider: ProviderConfig["provider"], authMethod: ProviderConfig["auth_method"]): Future => { switch (provider) { case "openai": return fetchOpenAIModels(authMethod); diff --git a/src/domain/commit/prompts.ts b/src/domain/commit/prompts.ts index aadf54a..da1ab56 100644 --- a/src/domain/commit/prompts.ts +++ b/src/domain/commit/prompts.ts @@ -281,9 +281,7 @@ function getRefinePrompt(params: { diff: string; currentMessage: string; adjustm } { return { prompt: - `\n${params.diff}\n\n` + - `\n${params.currentMessage}\n\n` + - `\n${params.adjustment}\n`, + `\n${params.diff}\n\n` + `\n${params.currentMessage}\n\n` + `\n${params.adjustment}\n`, systemInstruction: "You revise commit messages. Use the diff and the user's adjustment to produce a polished commit message. " + "Preserve required formatting rules: SMALL=single line; MEDIUM/LARGE=title, blank line, bullets prefixed with '- '. " + diff --git a/src/domain/llm/auth-resolver.ts b/src/domain/llm/auth-resolver.ts index 2c7e902..17f2832 100644 --- a/src/domain/llm/auth-resolver.ts +++ b/src/domain/llm/auth-resolver.ts @@ -12,11 +12,7 @@ type DetectTokenChange = (original: T, fresh: T) => May type RefreshProvider = (tokens: T) => Future; type PersistProvider = (tokens: T) => Future; -type RefreshAndPersistFlow = ( - tokens: T, - refresh: RefreshProvider, - persist: PersistProvider -) => Future; +type RefreshAndPersistFlow = (tokens: T, refresh: RefreshProvider, persist: PersistProvider) => Future; type ResolveProvider = (config: Config) => Future; diff --git a/src/domain/llm/effort.ts b/src/domain/llm/effort.ts index 23e0b8c..efc2313 100644 --- a/src/domain/llm/effort.ts +++ b/src/domain/llm/effort.ts @@ -12,12 +12,7 @@ export { import { type ThinkingConfig, ThinkingLevel } from "@google/genai"; import { type Future } from "@/libs/future"; import { Nothing, type Maybe } from "@/libs/maybe"; -import { - type ProviderConfig, - type OpenAIEffort, - type AnthropicEffort, - type GeminiEffort -} from "@/domain/config/config"; +import { type ProviderConfig, type OpenAIEffort, type AnthropicEffort, type GeminiEffort } from "@/domain/config/config"; import { selectOpenAIEffort, selectAnthropicEffort, selectGeminiEffort } from "@/infra/ui/effort-picker"; import { absurd } from "@/libs/types"; @@ -32,13 +27,10 @@ const openaiReasoningParam = (effort: Maybe): { reasoning: OpenAI. const anthropicAdaptiveParam = ( effort: Maybe ): { thinking: Anthropic.ThinkingConfigAdaptive; output_config: Anthropic.OutputConfig } | undefined => - effort.maybe<{ thinking: Anthropic.ThinkingConfigAdaptive; output_config: Anthropic.OutputConfig } | undefined>( - undefined, - (e) => ({ - thinking: { type: "adaptive" }, - output_config: { effort: e } - }) - ); + effort.maybe<{ thinking: Anthropic.ThinkingConfigAdaptive; output_config: Anthropic.OutputConfig } | undefined>(undefined, (e) => ({ + thinking: { type: "adaptive" }, + output_config: { effort: e } + })); const BUDGET_BY_EFFORT: Record = { low: 1024, @@ -84,11 +76,7 @@ const geminiBudgetConfig = (effort: Maybe): { thinkingConfig: Thin thinkingConfig: { thinkingBudget: BUDGET_BY_LEVEL[e] } })); -const seedProviderConfig = ( - provider: ProviderConfig["provider"], - model: string, - auth_method: ProviderConfig["auth_method"] -): ProviderConfig => { +const seedProviderConfig = (provider: ProviderConfig["provider"], model: string, auth_method: ProviderConfig["auth_method"]): ProviderConfig => { switch (provider) { case "openai": return { provider, model, auth_method, effort: Nothing() }; diff --git a/src/domain/llm/router.ts b/src/domain/llm/router.ts index ddaa7e8..231b253 100644 --- a/src/domain/llm/router.ts +++ b/src/domain/llm/router.ts @@ -31,9 +31,5 @@ const generateCommitMessage = ( customTemplate: Maybe = Nothing() ): Future => generateContent(config, { prompt: getPrompt(diff, convention, customTemplate) }); -const refineCommitMessage = ( - config: ProviderConfig, - currentMessage: string, - adjustment: string, - diff: string -): Future => generateContent(config, getRefinePrompt({ diff, currentMessage, adjustment })); +const refineCommitMessage = (config: ProviderConfig, currentMessage: string, adjustment: string, diff: string): Future => + generateContent(config, getRefinePrompt({ diff, currentMessage, adjustment })); diff --git a/src/infra/auth/google.ts b/src/infra/auth/google.ts index 633718f..a192309 100644 --- a/src/infra/auth/google.ts +++ b/src/infra/auth/google.ts @@ -34,11 +34,7 @@ const findAvailablePort = (): Future => Future.create((reject, resolve) => { const tryPort = (port: number): void => { if (port > PORT_RANGE_END) { - reject( - new Error( - `No available port found in range ${PORT_RANGE_START}-${PORT_RANGE_END}. Close other applications and try again.` - ) - ); + reject(new Error(`No available port found in range ${PORT_RANGE_START}-${PORT_RANGE_END}. Close other applications and try again.`)); return; } @@ -131,12 +127,7 @@ const openBrowser = (url: string): Future => return Future.resolve(undefined); }); -const exchangeCodeForTokens = ( - client: OAuth2Client, - code: string, - codeVerifier: string, - redirectUri: string -): Future => +const exchangeCodeForTokens = (client: OAuth2Client, code: string, codeVerifier: string, redirectUri: string): Future => Future.attemptP(async () => { const { tokens } = await client.getToken({ code, @@ -179,29 +170,15 @@ const performOAuthFlow = (): Future => state }); - return Future.bracket( - startCallbackServer(port, state), - stopCallbackServer, - (cs) => { - const waitForCode: Future = openBrowser(authUrl).chain(() => - Future.attemptP(() => cs.codePromise) - ); - - const timeout: Future = Future.create((reject) => { - return () => - clearTimeout( - setTimeout( - () => reject(new Error("OAuth flow timed out after 5 minutes. Please try again.")), - OAUTH_TIMEOUT_MS - ) - ); - }); + return Future.bracket(startCallbackServer(port, state), stopCallbackServer, (cs) => { + const waitForCode: Future = openBrowser(authUrl).chain(() => Future.attemptP(() => cs.codePromise)); - return Future.race(waitForCode, timeout).chain((code) => - exchangeCodeForTokens(client, code, codeVerifier, redirectUri) - ); - } - ); + const timeout: Future = Future.create((reject) => { + return () => clearTimeout(setTimeout(() => reject(new Error("OAuth flow timed out after 5 minutes. Please try again.")), OAUTH_TIMEOUT_MS)); + }); + + return Future.race(waitForCode, timeout).chain((code) => exchangeCodeForTokens(client, code, codeVerifier, redirectUri)); + }); }); const createAuthenticatedClient = (tokens: OAuthTokens): Future => { @@ -249,23 +226,17 @@ const ensureFreshTokens = (tokens: OAuthTokens): Future => { .mapRej((err) => { const message = String(err); if (message.includes("invalid_grant")) { - return new Error( - "OAuth tokens have been revoked. Please run 'commit-tools setup' or 'commit-tools login' to re-authenticate." - ); + return new Error("OAuth tokens have been revoked. Please run 'commit-tools setup' or 'commit-tools login' to re-authenticate."); } if (message.includes("invalid_client")) { - return new Error( - "OAuth client credentials are invalid. Check GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in your .env file." - ); + return new Error("OAuth client credentials are invalid. Check GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in your .env file."); } return new Error(`Token refresh failed: ${message}`); }); }; const validateOAuthTokens = (tokens: OAuthTokens): Future => - tokens.access_token && tokens.access_token.length > 0 ? - Future.resolve(undefined) - : Future.reject(new Error("No valid access token available")); + tokens.access_token && tokens.access_token.length > 0 ? Future.resolve(undefined) : Future.reject(new Error("No valid access token available")); const getAccessToken = (tokens: OAuthTokens): Future => tokens.access_token ? Future.resolve(tokens.access_token) : Future.reject(new Error("No access token provided")); diff --git a/src/infra/auth/openai.ts b/src/infra/auth/openai.ts index 3e261c4..647c7bd 100644 --- a/src/infra/auth/openai.ts +++ b/src/infra/auth/openai.ts @@ -171,27 +171,16 @@ const performOpenAIOAuthFlow = (): Future => authUrl.searchParams.set("state", state); authUrl.searchParams.set("originator", "codex_cli_rs"); - return Future.bracket( - startCallbackServer(port, state), - stopCallbackServer, - (cs) => { - const waitForCode: Future = openBrowser(authUrl.toString()).chain(() => - Future.attemptP(() => cs.codePromise) - ); - - const timeout: Future = Future.create((reject) => { - const timer = setTimeout( - () => reject(new Error("OAuth flow timed out after 5 minutes. Please try again.")), - OAUTH_TIMEOUT_MS - ); - return () => clearTimeout(timer); - }); - - return Future.race(waitForCode, timeout).chain((code) => - exchangeCodeForTokens(code, codeVerifier, redirectUri) - ); - } - ); + return Future.bracket(startCallbackServer(port, state), stopCallbackServer, (cs) => { + const waitForCode: Future = openBrowser(authUrl.toString()).chain(() => Future.attemptP(() => cs.codePromise)); + + const timeout: Future = Future.create((reject) => { + const timer = setTimeout(() => reject(new Error("OAuth flow timed out after 5 minutes. Please try again.")), OAUTH_TIMEOUT_MS); + return () => clearTimeout(timer); + }); + + return Future.race(waitForCode, timeout).chain((code) => exchangeCodeForTokens(code, codeVerifier, redirectUri)); + }); }); const ensureFreshOpenAITokens = (tokens: OpenAITokens): Future => { @@ -242,11 +231,7 @@ const ensureFreshOpenAITokens = (tokens: OpenAITokens): Future => - tokens.access_token && tokens.access_token.length > 0 ? - Future.resolve(undefined) - : Future.reject(new Error("No valid OpenAI access token available")); + tokens.access_token && tokens.access_token.length > 0 ? Future.resolve(undefined) : Future.reject(new Error("No valid OpenAI access token available")); const getOpenAIAccessToken = (tokens: OpenAITokens): Future => - tokens.access_token ? - Future.resolve(tokens.access_token) - : Future.reject(new Error("No OpenAI access token provided")); + tokens.access_token ? Future.resolve(tokens.access_token) : Future.reject(new Error("No OpenAI access token provided")); diff --git a/src/infra/git/repo.ts b/src/infra/git/repo.ts index 3943af4..bc62865 100644 --- a/src/infra/git/repo.ts +++ b/src/infra/git/repo.ts @@ -44,10 +44,7 @@ type PushResult = { range: Maybe; }; -type BaseLookupError = - | { type: "reflog-empty" } - | { type: "reflog-not-creation"; subject: string } - | { type: "reflog-cmd-failed"; message: string }; +type BaseLookupError = { type: "reflog-empty" } | { type: "reflog-not-creation"; subject: string } | { type: "reflog-cmd-failed"; message: string }; const CREATED_FROM_RE = /^branch: Created from (\S+)$/; @@ -78,14 +75,11 @@ const parsePushRange = (output: string): Maybe => { return before && after ? Just({ before, after }) : Nothing(); }; -const checkIsGitRepo = (): Future => - execGitChecked(["rev-parse", "--is-inside-work-tree"], "Not a git repository").map(() => {}); +const checkIsGitRepo = (): Future => execGitChecked(["rev-parse", "--is-inside-work-tree"], "Not a git repository").map(() => {}); const getStagedDiff = (): Future => execGitChecked(["diff", "--staged"], "Failed to get staged changes").chain((stdout) => - stdout.trim() ? - Future.resolve(stdout) - : Future.reject(new Error("No staged changes found")) + stdout.trim() ? Future.resolve(stdout) : Future.reject(new Error("No staged changes found")) ); const performCommit = (message: string): Future => { @@ -126,11 +120,19 @@ const findCurrentBranch = (): Future> => .chainRej(() => Future.resolve>(Nothing())); const hasUpstream = (): Future => - execBin("git", ["rev-parse", "--abbrev-ref", "@{u}"]).map((result) => result.either(() => false, () => true)); + execBin("git", ["rev-parse", "--abbrev-ref", "@{u}"]).map((result) => + result.either( + () => false, + () => true + ) + ); const getUpstream = (): Future> => execBin("git", ["rev-parse", "--abbrev-ref", "@{u}"]).map((result) => - result.either(() => Nothing(), ({ stdout }) => Just(stdout.trim())) + result.either( + () => Nothing(), + ({ stdout }) => Just(stdout.trim()) + ) ); const oldestReflogSubject = (stdout: string): Result => { @@ -188,8 +190,7 @@ const getBaseBranch = (): Future> => ) ); -const findBaseBranch = (): Future> => - getBaseBranch().chainRej(() => Future.resolve>(Nothing())); +const findBaseBranch = (): Future> => getBaseBranch().chainRej(() => Future.resolve>(Nothing())); const getRemoteUrl = (remote: string = "origin"): Future => execGitChecked(["remote", "get-url", remote], `Failed to read remote '${remote}' url`).map((s) => s.trim()); @@ -211,15 +212,13 @@ const findTrackingRemoteUrl = (): Future> => .chainRej(() => Future.resolve>(Nothing())); const getCommitMetadata = (ref: string = "HEAD"): Future => - execGitChecked(["log", "-1", `--format=%H%n%h%n%s%n%an%n%ae%n%aI`, ref], "Failed to read commit metadata").chain( - (stdout) => { - const [hash, short, subject, authorName, authorEmail, iso] = stdout.split("\n"); - // TODO: This is specially hard to understand and maintain, consider using a more robust serialization format in the future (e.g. JSON output from git log with a custom format) - return hash && short && subject !== undefined && authorName !== undefined && authorEmail !== undefined && iso ? - Future.resolve({ hash, short, subject, authorName, authorEmail, date: new Date(iso) }) - : Future.reject(new Error("Malformed git log output")); - } - ); + execGitChecked(["log", "-1", `--format=%H%n%h%n%s%n%an%n%ae%n%aI`, ref], "Failed to read commit metadata").chain((stdout) => { + const [hash, short, subject, authorName, authorEmail, iso] = stdout.split("\n"); + // TODO: This is specially hard to understand and maintain, consider using a more robust serialization format in the future (e.g. JSON output from git log with a custom format) + return hash && short && subject !== undefined && authorName !== undefined && authorEmail !== undefined && iso ? + Future.resolve({ hash, short, subject, authorName, authorEmail, date: new Date(iso) }) + : Future.reject(new Error("Malformed git log output")); + }); const findCommitMetadata = (ref: string = "HEAD"): Future> => getCommitMetadata(ref) diff --git a/src/infra/github/pr.ts b/src/infra/github/pr.ts index 4cf0386..71fdb7c 100644 --- a/src/infra/github/pr.ts +++ b/src/infra/github/pr.ts @@ -9,11 +9,7 @@ import { execBin, type CommandFailure } from "@/infra/shell"; type PullRequest = { url: string; number: number }; -type PrLookup = - | { type: "found"; pr: PullRequest } - | { type: "not-found" } - | { type: "unauthenticated" } - | { type: "unavailable" }; +type PrLookup = { type: "found"; pr: PullRequest } | { type: "not-found" } | { type: "unauthenticated" } | { type: "unavailable" }; // TODO: This looks like a "magical number", we need to think more about this const GITHUB_REPO_RE = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/; @@ -36,15 +32,14 @@ const parsePrJson = (stdout: string): PrLookup => (pr): PrLookup => ({ type: "found", pr }) ); -const commandFailureText = (failure: CommandFailure): string => - failure.output.stderr.trim() || failure.output.stdout.trim() || failure.error.message; +const commandFailureText = (failure: CommandFailure): string => failure.output.stderr.trim() || failure.output.stdout.trim() || failure.error.message; const classifyFailure = (failure: CommandFailure): PrLookup => { - return GH_UNAUTH_RE.test(commandFailureText(failure)) - ? { type: "unauthenticated" } - : GH_NOT_FOUND_RE.test(commandFailureText(failure)) - ? { type: "not-found" } - : { type: "unavailable" }; + return ( + GH_UNAUTH_RE.test(commandFailureText(failure)) ? { type: "unauthenticated" } + : GH_NOT_FOUND_RE.test(commandFailureText(failure)) ? { type: "not-found" } + : { type: "unavailable" } + ); }; const getOpenPullRequest = (): Future => diff --git a/src/infra/llm/anthropic.ts b/src/infra/llm/anthropic.ts index 71cb220..7bd8a72 100644 --- a/src/infra/llm/anthropic.ts +++ b/src/infra/llm/anthropic.ts @@ -77,12 +77,7 @@ const buildSetupTokenParams = ( return base; }; -const callAnthropicWithApiKey = ( - apiKey: string, - model: string, - effort: Maybe, - params: GenerateContentParams -): Future => { +const callAnthropicWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => { const run = (stage: Stage): Future => Future.attemptP(async () => { const client = new Anthropic({ apiKey }); @@ -119,14 +114,9 @@ const buildAttempts = ( effort: Maybe, run: (stage: Stage) => Future ): readonly [EffortAttempt, ...EffortAttempt[]] => - anthropicAdaptiveParam(effort) !== undefined ? - [() => run("adaptive"), () => run("enabled"), () => run("off")] - : [() => run("off")]; + anthropicAdaptiveParam(effort) !== undefined ? [() => run("adaptive"), () => run("enabled"), () => run("off")] : [() => run("off")]; -const generateContentWithAnthropic = ( - config: AnthropicConfig, - params: GenerateContentParams -): Future => { +const generateContentWithAnthropic = (config: AnthropicConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { case "api_key": return callAnthropicWithApiKey(config.auth_method.content, config.model, config.effort, params); diff --git a/src/infra/llm/effort-fallback.ts b/src/infra/llm/effort-fallback.ts index 73fec11..1c792d6 100644 --- a/src/infra/llm/effort-fallback.ts +++ b/src/infra/llm/effort-fallback.ts @@ -5,8 +5,7 @@ import { Future } from "@/libs/future"; type EffortAttempt = () => Future; -const EFFORT_FIELD_RE = - /reasoning|thinking|thinking_config|output_config|budget_tokens|thinkingconfig|thinkinglevel|thinkingbudget/i; +const EFFORT_FIELD_RE = /reasoning|thinking|thinking_config|output_config|budget_tokens|thinkingconfig|thinkinglevel|thinkingbudget/i; const BAD_REQUEST_RE = /(\b400\b|invalid_request|unsupported_parameter|bad_request|invalid_parameter)/i; const isEffortRejection = (err: Error): boolean => { diff --git a/src/infra/llm/gemini.ts b/src/infra/llm/gemini.ts index 826f21a..07e722d 100644 --- a/src/infra/llm/gemini.ts +++ b/src/infra/llm/gemini.ts @@ -13,9 +13,7 @@ import { tryWithEffort, type EffortAttempt } from "@/infra/llm/effort-fallback"; type GeminiConfig = Extract; -type GeminiAuthCredentials = - | { readonly method: "api_key"; readonly apiKey: string } - | { readonly method: "google_oauth"; readonly tokens: OAuthTokens }; +type GeminiAuthCredentials = { readonly method: "api_key"; readonly apiKey: string } | { readonly method: "google_oauth"; readonly tokens: OAuthTokens }; type Stage = "level" | "budget" | "off"; @@ -34,11 +32,7 @@ const getAuthCredentials = (config: Config): Maybe => { } }; -const buildConfigForStage = ( - effort: Maybe, - params: GenerateContentParams, - stage: Stage -): GenerateContentConfig => { +const buildConfigForStage = (effort: Maybe, params: GenerateContentParams, stage: Stage): GenerateContentConfig => { const base: GenerateContentConfig = {}; if (params.systemInstruction !== undefined) base.systemInstruction = params.systemInstruction; if (stage === "level") Object.assign(base, geminiLevelConfig(effort)); @@ -50,16 +44,9 @@ const buildAttempts = ( effort: Maybe, run: (stage: Stage) => Future ): readonly [EffortAttempt, ...EffortAttempt[]] => - geminiLevelConfig(effort) !== undefined ? - [() => run("level"), () => run("budget"), () => run("off")] - : [() => run("off")]; + geminiLevelConfig(effort) !== undefined ? [() => run("level"), () => run("budget"), () => run("off")] : [() => run("off")]; -const generateContentWithApiKey = ( - apiKey: string, - model: string, - effort: Maybe, - params: GenerateContentParams -): Future => { +const generateContentWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => { const run = (stage: Stage): Future => Future.attemptP(async () => { const ai = new GoogleGenAI({ apiKey }); @@ -72,11 +59,7 @@ const generateContentWithApiKey = ( return tryWithEffort(buildAttempts(effort, run)); }; -const buildOAuthBody = ( - effort: Maybe, - params: GenerateContentParams, - stage: Stage -): Record => { +const buildOAuthBody = (effort: Maybe, params: GenerateContentParams, stage: Stage): Record => { const body: Record = { contents: [{ parts: [{ text: params.prompt }] }] }; diff --git a/src/infra/llm/openai.ts b/src/infra/llm/openai.ts index 9717f35..23ee146 100644 --- a/src/infra/llm/openai.ts +++ b/src/infra/llm/openai.ts @@ -15,12 +15,7 @@ type OpenAIConfig = Extract; const toError = (error: unknown): Error => (error instanceof Error ? error : new Error(String(error))); -const callOpenAIWithApiKey = ( - authToken: string, - model: string, - effort: Maybe, - params: GenerateContentParams -): Future => { +const callOpenAIWithApiKey = (authToken: string, model: string, effort: Maybe, params: GenerateContentParams): Future => { const run = (withReasoning: boolean): Future => Future.attemptP(async () => { const client = new OpenAI({ apiKey: authToken }); @@ -43,12 +38,7 @@ const callOpenAIWithApiKey = ( return tryWithEffort(attempts); }; -const callOpenAIWithOAuth = ( - authToken: string, - model: string, - effort: Maybe, - params: GenerateContentParams -): Future => { +const callOpenAIWithOAuth = (authToken: string, model: string, effort: Maybe, params: GenerateContentParams): Future => { const run = (withReasoning: boolean): Future => Future.attemptP(async () => { const client = new OpenAI({ @@ -91,20 +81,15 @@ const callOpenAIWithOAuth = ( return tryWithEffort(attempts); }; -const generateContentWithApiKey = ( - apiKey: string, - model: string, - effort: Maybe, - params: GenerateContentParams -): Future => callOpenAIWithApiKey(apiKey, model, effort, params); +const generateContentWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => + callOpenAIWithApiKey(apiKey, model, effort, params); const generateContentWithOAuth = ( tokens: OpenAITokens, model: string, effort: Maybe, params: GenerateContentParams -): Future => - getOpenAIAccessToken(tokens).chain((accessToken) => callOpenAIWithOAuth(accessToken, model, effort, params)); +): Future => getOpenAIAccessToken(tokens).chain((accessToken) => callOpenAIWithOAuth(accessToken, model, effort, params)); const generateContentWithOpenAI = (config: OpenAIConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { diff --git a/src/infra/shell.ts b/src/infra/shell.ts index 0b11a04..012f305 100644 --- a/src/infra/shell.ts +++ b/src/infra/shell.ts @@ -11,18 +11,21 @@ type ExecResult = Result; const commandResult = (output: CommandOutput, exitCode: number | null, signal: NodeJS.Signals | null): ExecResult => exitCode === 0 ? Success(output) - : Failure({ output, error: new Error(`Command failed with exit code ${exitCode}${signal ? ` and signal ${signal}` : ""}`) }); + : Failure({ + output, + error: new Error(`Command failed with exit code ${exitCode}${signal ? ` and signal ${signal}` : ""}`) + }); const execBin = (bin: string, args: string[]): Future => Future.create((reject, resolve) => { const proc = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] }); - + let stdout = ""; let stderr = ""; proc.stdout.on("data", (d: Buffer) => (stdout += d.toString())); proc.stderr.on("data", (d: Buffer) => (stderr += d.toString())); - + proc.on("error", (err) => reject(new Error(`Failed to start process: ${err.message}`))); proc.on("close", (exitCode, signal) => resolve(commandResult({ stdout, stderr }, exitCode, signal))); diff --git a/src/infra/ui/effort-picker.ts b/src/infra/ui/effort-picker.ts index 32a2221..e76df40 100644 --- a/src/infra/ui/effort-picker.ts +++ b/src/infra/ui/effort-picker.ts @@ -4,23 +4,11 @@ import { ThinkingLevel } from "@google/genai"; import { Future } from "@/libs/future"; import { Just, Nothing, type Maybe } from "@/libs/maybe"; -import { - OPENAI_EFFORTS, - ANTHROPIC_EFFORTS, - GEMINI_EFFORTS, - type OpenAIEffort, - type AnthropicEffort, - type GeminiEffort -} from "@/domain/config/config"; +import { OPENAI_EFFORTS, ANTHROPIC_EFFORTS, GEMINI_EFFORTS, type OpenAIEffort, type AnthropicEffort, type GeminiEffort } from "@/domain/config/config"; type EffortSliderModule = typeof import("@/infra/ui/effort-slider"); -const selectEffort = ( - options: readonly V[], - modelId: string, - currentEffort: Maybe, - defaultValue: V -): Future> => { +const selectEffort = (options: readonly V[], modelId: string, currentEffort: Maybe, defaultValue: V): Future> => { const initialIndex = currentEffort.maybe(Math.max(0, options.indexOf(defaultValue)), (v) => { const idx = options.indexOf(v); return idx >= 0 ? idx : Math.max(0, options.indexOf(defaultValue)); @@ -55,10 +43,8 @@ const selectEffort = ( const selectOpenAIEffort = (modelId: string, current: Maybe): Future> => selectEffort(OPENAI_EFFORTS, modelId, current, "medium"); -const selectAnthropicEffort = ( - modelId: string, - current: Maybe -): Future> => selectEffort(ANTHROPIC_EFFORTS, modelId, current, "high"); +const selectAnthropicEffort = (modelId: string, current: Maybe): Future> => + selectEffort(ANTHROPIC_EFFORTS, modelId, current, "high"); const selectGeminiEffort = (modelId: string, current: Maybe): Future> => selectEffort(GEMINI_EFFORTS, modelId, current, ThinkingLevel.HIGH); diff --git a/src/infra/ui/effort-slider.tsx b/src/infra/ui/effort-slider.tsx index be3acdf..3d2b55d 100644 --- a/src/infra/ui/effort-slider.tsx +++ b/src/infra/ui/effort-slider.tsx @@ -26,8 +26,7 @@ const PALETTE: Record = { const paletteFor = (n: number): readonly ChalkColor[] => PALETTE[n] ?? PALETTE[6]!; -const colorize = (color: ChalkColor, text: string, bold: boolean): string => - bold ? chalk[color].bold(text) : chalk[color](text); +const colorize = (color: ChalkColor, text: string, bold: boolean): string => (bold ? chalk[color].bold(text) : chalk[color](text)); const EffortSlider = ({ title, options, initialIndex, onSubmit, onCancel }: EffortSliderProps) => { const { exit } = useApp(); @@ -72,9 +71,7 @@ const EffortSlider = ({ title, options, initialIndex, onSubmit const markerCol = index * step; const markerColor = palette[index] ?? "cyan"; - const railChars = Array.from({ length: cols }).map((_, c) => - c === markerCol ? chalk[markerColor]("▲") : chalk.dim("─") - ); + const railChars = Array.from({ length: cols }).map((_, c) => (c === markerCol ? chalk[markerColor]("▲") : chalk.dim("─"))); const rail = railChars.join(""); const labelParts: string[] = options.map((opt, i) => { diff --git a/src/infra/ui/push-note.ts b/src/infra/ui/push-note.ts index 75251a2..43c3da3 100644 --- a/src/infra/ui/push-note.ts +++ b/src/infra/ui/push-note.ts @@ -45,14 +45,7 @@ const renderPushNote = (m: PushMetadata): void => { const remoteLine = m.remoteUrl.maybe([], (url) => [`remote ${url}`]); const rangeLine = m.range.maybe([], (range) => [`range ${range.before}..${range.after}`]); - const body = [ - ...renderCommitLines(m.commit), - ...branchLine, - ...baseLine, - ...remoteLine, - ...rangeLine, - ...renderPrLine(m.pr) - ].join("\n"); + const body = [...renderCommitLines(m.commit), ...branchLine, ...baseLine, ...remoteLine, ...rangeLine, ...renderPrLine(m.pr)].join("\n"); if (!body) return; diff --git a/src/libs/future.ts b/src/libs/future.ts index 30c673d..58364a8 100644 --- a/src/libs/future.ts +++ b/src/libs/future.ts @@ -76,10 +76,7 @@ class Future { } static traverse(f: (_: A) => Future, xs: Array): Future> { - return xs.reduce( - (acc, x) => acc.chain((ys) => f(x).map((y) => [...ys, y])), - Future.resolve([]) as Future> - ); + return xs.reduce((acc, x) => acc.chain((ys) => f(x).map((y) => [...ys, y])), Future.resolve([]) as Future>); } static resolveAfter(milliseconds: number, value: T): Future { diff --git a/src/libs/helpers/object.ts b/src/libs/helpers/object.ts index dc3c612..a50e3ca 100644 --- a/src/libs/helpers/object.ts +++ b/src/libs/helpers/object.ts @@ -1,9 +1,6 @@ export { filterKeys, filterMap, mapValues }; -function filterKeys( - obj: O, - pred: (key: KK, value: O[KK]) => boolean -): Partial { +function filterKeys(obj: O, pred: (key: KK, value: O[KK]) => boolean): Partial { const result: Partial = {}; const keys = Object.keys(obj) as K[]; for (const key of keys) { @@ -15,10 +12,7 @@ function filterKeys( } // Filter keys and transform values at the same time -function filterMap( - obj: O, - fn: (key: K, value: O[K]) => R | undefined -): Partial<{ [K in keyof O]: R }> { +function filterMap(obj: O, fn: (key: K, value: O[K]) => R | undefined): Partial<{ [K in keyof O]: R }> { const result = {} as Partial<{ [K in keyof O]: R }>; const keys = Object.keys(obj) as Array; for (const key of keys) { @@ -30,10 +24,7 @@ function filterMap( return result; } -function mapValues( - obj: T, - fn: (key: K, value: T[K]) => R -): { [K in keyof T]: R } { +function mapValues(obj: T, fn: (key: K, value: T[K]) => R): { [K in keyof T]: R } { const out = {} as { [K in keyof T]: R }; const keys = Object.keys(obj) as Array; for (const k of keys) { diff --git a/src/libs/json/decoder.ts b/src/libs/json/decoder.ts index 699961a..fdc301c 100644 --- a/src/libs/json/decoder.ts +++ b/src/libs/json/decoder.ts @@ -127,22 +127,16 @@ const both = (left: Decoder, right: Decoder): Decoder<[T, U]> => return Success([l.value, r.value]); }); -const string: Decoder = new Decoder((v) => - typeof v === "string" ? Success(v) : failure("expected string but found " + typeof v) -); +const string: Decoder = new Decoder((v) => (typeof v === "string" ? Success(v) : failure("expected string but found " + typeof v))); -const number: Decoder = new Decoder((v) => - typeof v === "number" ? Success(v) : failure("expected number but found " + typeof v) -); +const number: Decoder = new Decoder((v) => (typeof v === "number" ? Success(v) : failure("expected number but found " + typeof v))); const stringNumber: Decoder = string.chain((s) => { const v = parseInt(s, 10); return isNaN(v) ? fail("not a valid number: " + s) : succeed(v); }); -const boolean: Decoder = new Decoder((v) => - typeof v === "boolean" ? Success(v) : failure("expected boolean but found " + typeof v) -); +const boolean: Decoder = new Decoder((v) => (typeof v === "boolean" ? Success(v) : failure("expected boolean but found " + typeof v))); const array = (decodeValue: Decoder): Decoder> => new Decoder((input) => { @@ -270,13 +264,9 @@ const maybe = (decoder: Decoder): Decoder> => const nullable = (decoder: Decoder): Decoder> => oneOf([nullP, decoder]); -const nullP: Decoder = new Decoder((v) => - v === null ? Success(null) : failure("expected null but found " + typeof v) -); +const nullP: Decoder = new Decoder((v) => (v === null ? Success(null) : failure("expected null but found " + typeof v))); -const undefinedP: Decoder = new Decoder((v) => - v === undefined ? Success(undefined) : failure("expected `undefined` " + typeof v) -); +const undefinedP: Decoder = new Decoder((v) => (v === undefined ? Success(undefined) : failure("expected `undefined` " + typeof v))); // Useful for parsing tag names in discriminated unions. const stringLiteral = (str: T): Decoder => @@ -297,8 +287,7 @@ class DecoderOptional { const optionalMaybe = (decoder: Decoder): DecoderOptional> => DecoderOptional.from(decoder); -const optionalNullable = (decoder: Decoder>): DecoderOptional> => - optionalMaybe(decoder).map((v) => v.asNullable()); +const optionalNullable = (decoder: Decoder>): DecoderOptional> => optionalMaybe(decoder).map((v) => v.asNullable()); // An object field that may be absent. const optional = (decoder: Decoder): DecoderOptional => diff --git a/src/libs/json/encoder.ts b/src/libs/json/encoder.ts index eb8d029..76d6172 100644 --- a/src/libs/json/encoder.ts +++ b/src/libs/json/encoder.ts @@ -114,8 +114,7 @@ const triple = (sA: Encoder, sB: Encoder, sC: Encoder): Encode const maybe = (encoder: Encoder): Encoder> => new Encoder((input) => (input instanceof Nothing ? { nothing: {} } : { just: encoder.run(input.value) }) as Json); -const nullable = (encoder: Encoder): Encoder> => - new Encoder((input) => (input === null ? null : encoder.run(input))); +const nullable = (encoder: Encoder): Encoder> => new Encoder((input) => (input === null ? null : encoder.run(input))); // An encoder for object keys that omits the field if the value is Nothing. class EncoderOptional { diff --git a/src/libs/json/schema.ts b/src/libs/json/schema.ts index 2064b88..5fe6344 100644 --- a/src/libs/json/schema.ts +++ b/src/libs/json/schema.ts @@ -109,8 +109,7 @@ class SchemaOptional { } // An object field that may be absent. -const optional = (s: Schema): SchemaOptional => - new SchemaOptional(D.optional(s.decoder), E.optional(s.encoder)); +const optional = (s: Schema): SchemaOptional => new SchemaOptional(D.optional(s.decoder), E.optional(s.encoder)); const optionalNullable = (schema: Schema>): SchemaOptional> => new SchemaOptional(D.optionalNullable(schema.decoder), E.optionalNullable(schema.encoder)); @@ -170,8 +169,7 @@ const stringLiteral = (str: T): Schema => }) ); -const stringEnum = (strs: T): Schema => - new Schema(D.stringEnum(strs), E.stringEnum(strs)); +const stringEnum = (strs: T): Schema => new Schema(D.stringEnum(strs), E.stringEnum(strs)); const oneOf = (f: (v: V) => Schema, ss: Array>): Schema => new Schema( @@ -179,9 +177,7 @@ const oneOf = (f: (v: V) => Schema, ss: Array>): Schema => E.oneOf((v) => f(v).encoder) ); -const discriminatedUnion = []>( - vars: Variants -): Schema> => { +const discriminatedUnion = []>(vars: Variants): Schema> => { type Ty = Infer; const d: D.Decoder = D.oneOf(vars.map((v) => v.schema.decoder)); const e: E.Encoder = E.oneOf((v) => { @@ -228,15 +224,10 @@ type VariantDef = { }; const variant = (def: VariantDef): Variant => { - const pattern = filterMap(def, (_, v): string | undefined => (typeof v == "string" ? v : undefined)) as Record< - string, - string - >; + const pattern = filterMap(def, (_, v): string | undefined => (typeof v == "string" ? v : undefined)) as Record; if (Object.keys(pattern).length == 0) { - throw new Error( - "Invalid variant definition. No discriminant identified. Discriminant must be provided as a string" - ); + throw new Error("Invalid variant definition. No discriminant identified. Discriminant must be provided as a string"); } const schemaDef: SchemaDef = mapValues(def, (_, value) => @@ -250,8 +241,7 @@ const variant = (def: VariantDef): Varian }; // Schema for a stringified JSON representation. -const stringified = (inner: Schema): Schema => - new Schema(D.stringified(inner.decoder), E.stringified(inner.encoder)); +const stringified = (inner: Schema): Schema => new Schema(D.stringified(inner.decoder), E.stringified(inner.encoder)); const recursive = (f: (s: Schema) => Schema): Schema => { const baseEncoder: Encoder = new Encoder((_) => { diff --git a/src/libs/maybe.ts b/src/libs/maybe.ts index fd92e83..be464e5 100644 --- a/src/libs/maybe.ts +++ b/src/libs/maybe.ts @@ -14,17 +14,7 @@ Values can be extracted using `instsanceof` tests. x satisfies never; } */ -export { - type Maybe, - type Nullable, - type Infer, - CallableJust as Just, - CallableNothing as Nothing, - fromOptional, - fromNullable, - catMaybes, - mapMaybe -}; +export { type Maybe, type Nullable, type Infer, CallableJust as Just, CallableNothing as Nothing, fromOptional, fromNullable, catMaybes, mapMaybe }; import Callable from "@/libs/callable"; diff --git a/src/libs/remote-data.ts b/src/libs/remote-data.ts index ff875df..4e17e0a 100644 --- a/src/libs/remote-data.ts +++ b/src/libs/remote-data.ts @@ -1,10 +1,4 @@ -export { - type RemoteData, - CallableSuccess as Ready, - CallableFailure as Failed, - CallableNotAsked as NotAsked, - CallableLoading as Loading -}; +export { type RemoteData, CallableSuccess as Ready, CallableFailure as Failed, CallableNotAsked as NotAsked, CallableLoading as Loading }; import { Nullable, Maybe, Nothing, Just } from "@/libs/maybe"; import Callable from "@/libs/callable"; diff --git a/src/libs/router.ts b/src/libs/router.ts index 22088ec..6e1e6b6 100644 --- a/src/libs/router.ts +++ b/src/libs/router.ts @@ -104,35 +104,16 @@ type Response = Render | JSON | Redirect | SSE; // Convenience constructors for responses. -const json = ({ - status = 200, - headers = {}, - content -}: { - status?: number; - headers?: Headers; - content: Json; -}): Response => new JSON({ status, headers, content }); +const json = ({ status = 200, headers = {}, content }: { status?: number; headers?: Headers; content: Json }): Response => + new JSON({ status, headers, content }); const redirect = (path: string): Response => new Redirect({ path }); -const render = ({ - status = 200, - headers = {}, - content -}: { - status?: number; - headers?: Headers; - content: string | Json; -}): Response => new Render({ status, headers, content }); - -const sse = ({ - headers = {}, - stream -}: { - headers?: Headers; - stream: (emit: SendMessage, onError: OnError) => Future; -}): Response => new SSE({ headers, stream }); +const render = ({ status = 200, headers = {}, content }: { status?: number; headers?: Headers; content: string | Json }): Response => + new Render({ status, headers, content }); + +const sse = ({ headers = {}, stream }: { headers?: Headers; stream: (emit: SendMessage, onError: OnError) => Future }): Response => + new SSE({ headers, stream }); // A middleware is something that transforms the environment. type Middleware = (req: express.Request, env: A) => Future; @@ -169,10 +150,7 @@ function send(response: Response, res: express.Response): void { } } -function streamSSEResponse( - res: express.Response, - stream: (emit: SendMessage, onError: OnError) => Future -): void { +function streamSSEResponse(res: express.Response, stream: (emit: SendMessage, onError: OnError) => Future): void { let connectionClosed = false; let endStream: Cancel = () => {}; const closeConnection = () => { diff --git a/src/libs/time.ts b/src/libs/time.ts index 5246d99..d6fbf00 100644 --- a/src/libs/time.ts +++ b/src/libs/time.ts @@ -357,10 +357,7 @@ class Duration { // Chained factory methods (seconds → milliseconds) accumulate // IEEE-754 floating point errors (~10⁻¹⁵). Rounding to milliseconds is safe // as it's our internal precision and the error is far below this threshold. - const total = Duration.days(days) - .add(Duration.hours(hours)) - .add(Duration.minutes(minutes)) - .add(Duration.seconds(seconds)); + const total = Duration.days(days).add(Duration.hours(hours)).add(Duration.minutes(minutes)).add(Duration.seconds(seconds)); const millis = Math.round(total.asMilliseconds()); return Just(Duration.milliseconds(negative ? -millis : millis)); diff --git a/src/libs/trampoline.ts b/src/libs/trampoline.ts index 7ef66f4..b752556 100644 --- a/src/libs/trampoline.ts +++ b/src/libs/trampoline.ts @@ -44,9 +44,7 @@ class Rec { type Fun = (...args: A) => B; -function fix( - f: Fun<[Fun>, (r: R) => Trampoline], Fun>> -): Fun { +function fix(f: Fun<[Fun>, (r: R) => Trampoline], Fun>>): Fun { let lazy_f: Fun> = (..._: A) => { throw new Error("recursion error"); }; diff --git a/src/libs/types.ts b/src/libs/types.ts index aafa799..537c476 100644 --- a/src/libs/types.ts +++ b/src/libs/types.ts @@ -31,8 +31,7 @@ type IsUnion = [T] extends [UnionToIntersection] ? false : true; // // UnionToTuple == [ A , B ] // -type UnionToTuple = - IsUnion extends true ? UnionToTuple>, [PopUnion, ...A]> : [T, ...A]; +type UnionToTuple = IsUnion extends true ? UnionToTuple>, [PopUnion, ...A]> : [T, ...A]; // Pick an option from a union // From 37966b757a84639d373a35ebc8cc74cba7604bc4 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Fri, 24 Apr 2026 22:36:13 -0300 Subject: [PATCH 10/38] Rename `withAuthMethod` to `resolveAuthMethod` --- src/domain/llm/auth-resolver.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/domain/llm/auth-resolver.ts b/src/domain/llm/auth-resolver.ts index 17f2832..a65edd5 100644 --- a/src/domain/llm/auth-resolver.ts +++ b/src/domain/llm/auth-resolver.ts @@ -13,7 +13,6 @@ type RefreshProvider = (tokens: T) => Future; type PersistProvider = (tokens: T) => Future; type RefreshAndPersistFlow = (tokens: T, refresh: RefreshProvider, persist: PersistProvider) => Future; - type ResolveProvider = (config: Config) => Future; const tokensChanged: DetectTokenChange = (original, fresh) => @@ -28,7 +27,7 @@ const refreshAndPersist: RefreshAndPersistFlow = (tokens, refresh, persist) => ); // TODO: WHY????? -const withAuthMethod = (ai: ProviderConfig, auth_method: ProviderConfig["auth_method"]): ProviderConfig => { +const resolveAuthMethod = (ai: ProviderConfig, auth_method: ProviderConfig["auth_method"]): ProviderConfig => { switch (ai.provider) { case "openai": return { provider: "openai", model: ai.model, auth_method, effort: ai.effort }; @@ -51,12 +50,12 @@ const resolveProvider: ResolveProvider = (config) => { case "google_oauth": return refreshAndPersist(ai.auth_method.content, ensureFreshTokens, updateGoogleTokens).map((tokens) => - withAuthMethod(ai, { type: "google_oauth", content: tokens }) + resolveAuthMethod(ai, { type: "google_oauth", content: tokens }) ); case "openai_oauth": return refreshAndPersist(ai.auth_method.content, ensureFreshOpenAITokens, updateOpenAITokens).map((tokens) => - withAuthMethod(ai, { type: "openai_oauth", content: tokens }) + resolveAuthMethod(ai, { type: "openai_oauth", content: tokens }) ); default: From aa871cb01f858667cfc5b3e923228d636e151c09 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Fri, 24 Apr 2026 22:37:00 -0300 Subject: [PATCH 11/38] Remove obsolete TODO comment in auth resolver --- src/domain/llm/auth-resolver.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/domain/llm/auth-resolver.ts b/src/domain/llm/auth-resolver.ts index a65edd5..b37ec98 100644 --- a/src/domain/llm/auth-resolver.ts +++ b/src/domain/llm/auth-resolver.ts @@ -26,7 +26,6 @@ const refreshAndPersist: RefreshAndPersistFlow = (tokens, refresh, persist) => ) ); -// TODO: WHY????? const resolveAuthMethod = (ai: ProviderConfig, auth_method: ProviderConfig["auth_method"]): ProviderConfig => { switch (ai.provider) { case "openai": From 21e29c543c3eaade5bee2b4378f332ff747ab8d9 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 10:34:48 -0300 Subject: [PATCH 12/38] Simplify response parser to a provider-agnostic text shape - Replace provider-tagged `RawResponse` union with a single `{ text: Maybe }` type in `domain/llm/response-parser`. - Move Anthropic content extraction (`extractAnthropicText`) into `infra/llm/anthropic.ts`. - Move OpenAI stream text extraction (`extractStreamText`) into `infra/llm/openai.ts`. - Wrap Gemini SDK and REST text into `Maybe` via `fromOptional` at the call sites. - Drop `finalizeText`, provider-specific types, and the switch-based dispatch from the domain layer. --- src/domain/llm/response-parser.ts | 92 ++++--------------------------- src/infra/llm/anthropic.ts | 12 +++- src/infra/llm/gemini.ts | 6 +- src/infra/llm/openai.ts | 25 ++++++++- 4 files changed, 45 insertions(+), 90 deletions(-) diff --git a/src/domain/llm/response-parser.ts b/src/domain/llm/response-parser.ts index 29ef5b3..8e46ac9 100644 --- a/src/domain/llm/response-parser.ts +++ b/src/domain/llm/response-parser.ts @@ -1,85 +1,15 @@ -export { type RawResponse, extractResponse, finalizeText }; +export { type RawResponse, extractResponse }; +import { Maybe, Just, Nothing } from "@/libs/maybe"; import { Future } from "@/libs/future"; -import { absurd } from "@/libs/types"; -const EMPTY_RESPONSE_ERROR = "Empty AI response"; +type RawResponse = { text: Maybe }; -const finalizeText = (raw: string | null | undefined): Future => { - const trimmed = (raw ?? "").trim(); - return trimmed.length === 0 ? Future.reject(new Error(EMPTY_RESPONSE_ERROR)) : Future.resolve(trimmed); -}; - -type TextBlock = { type: "text"; text: string }; -type AnthropicContent = Array<{ type: string; text?: string }>; - -type GeminiSDKLike = { text?: string | null | undefined }; -type GeminiRESTLike = { - candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; -}; - -type OpenAIDirectLike = { output_text?: string | null }; -type OpenAIStreamLike = { - response: { - output: Array<{ type: string; content?: Array<{ type: string; text?: string }> }>; - output_text?: string | null; - }; - doneEventText: string; - deltaSnapshotText: string; -}; - -type RawResponse = - | { provider: "gemini"; source: "sdk"; value: GeminiSDKLike } - | { provider: "gemini"; source: "rest"; value: GeminiRESTLike } - | { provider: "anthropic"; value: { content: AnthropicContent } } - | { provider: "openai"; source: "direct"; value: OpenAIDirectLike } - | { provider: "openai"; source: "stream"; value: OpenAIStreamLike }; - -const extractAnthropicText = (content: AnthropicContent): string => - content - .filter((b): b is TextBlock => b.type === "text" && typeof b.text === "string") - .map((b) => b.text) - .join(""); - -const extractOpenAIStreamText = (raw: OpenAIStreamLike): string => { - const fromOutput = raw.response.output - .flatMap((item) => (item.type === "message" ? (item.content ?? []) : [])) - .map((c) => (c.type === "output_text" ? (c.text ?? "") : "")) - .join(""); - - const candidates = [fromOutput, raw.response.output_text ?? "", raw.doneEventText, raw.deltaSnapshotText]; - return candidates.find((v) => v.trim().length > 0) ?? ""; -}; - -const extractResponse = (raw: RawResponse): Future => { - switch (raw.provider) { - case "gemini": - // TODO: THIS NEEDS TO BE REVIEWED: - // 1. Why do we have two different response shapes for Gemini? SDK vs REST? We just need to have one. - // 2. Why we have two? - switch (raw.source) { - case "sdk": - return finalizeText(raw.value.text); - case "rest": - return finalizeText(raw.value.candidates?.[0]?.content?.parts?.[0]?.text); - default: - return absurd(raw, "RawResponse.gemini"); - } - case "anthropic": - return finalizeText(extractAnthropicText(raw.value.content)); - case "openai": - // TODO: THIS NEEDS TO BE REVIEWED: - // 1. Why do we have two different response shapes for OpenAI? What means "direct" vs "stream"? Can't we unify this in the infra layer and have just one shape here? We just need to have one. - // 2. Why we have two? the answer is the same as above, only one response shape is needed. - switch (raw.source) { - case "direct": - return finalizeText(raw.value.output_text); - case "stream": - return finalizeText(extractOpenAIStreamText(raw.value)); - default: - return absurd(raw, "RawResponse.openai"); - } - default: - return absurd(raw, "RawResponse"); - } -}; +const extractResponse = (raw: RawResponse): Future => + raw.text + .map((s) => s.trim()) + .chain((s) => (s.length === 0 ? Nothing() : Just(s))) + .unwrap( + () => Future.reject(new Error("Response text is empty or missing")), + (s) => Future.resolve(s) + ); diff --git a/src/infra/llm/anthropic.ts b/src/infra/llm/anthropic.ts index 7bd8a72..19ab2e3 100644 --- a/src/infra/llm/anthropic.ts +++ b/src/infra/llm/anthropic.ts @@ -10,7 +10,7 @@ import { absurd } from "@/libs/types"; import { extractResponse } from "@/domain/llm/response-parser"; import { anthropicAdaptiveParam, anthropicEnabledParam } from "@/domain/llm/effort"; import { tryWithEffort, type EffortAttempt } from "@/infra/llm/effort-fallback"; -import { type Maybe } from "@/libs/maybe"; +import { Just, type Maybe } from "@/libs/maybe"; type AnthropicConfig = Extract; @@ -23,6 +23,12 @@ const BASE_MAX_TOKENS = 4096; // TODO: We really need this? const toError = (error: unknown): Error => (error instanceof Error ? error : new Error(String(error))); +const extractAnthropicText = (content: Array<{ type: string; text?: string }>): string => + content + .filter((b): b is TextBlock => b.type === "text" && typeof b.text === "string") + .map((b) => b.text) + .join(""); + const buildApiKeyParams = ( model: string, effort: Maybe, @@ -84,7 +90,7 @@ const callAnthropicWithApiKey = (apiKey: string, model: string, effort: Maybe extractResponse({ provider: "anthropic", value: response })); + .chain((response) => extractResponse({ text: Just(extractAnthropicText(response.content)) })); return tryWithEffort(buildAttempts(effort, run)); }; @@ -105,7 +111,7 @@ const callAnthropicWithSetupToken = ( return await client.messages.create(buildSetupTokenParams(model, effort, params, stage)); }) .mapRej(toError) - .chain((response) => extractResponse({ provider: "anthropic", value: response })); + .chain((response) => extractResponse({ text: Just(extractAnthropicText(response.content)) })); return tryWithEffort(buildAttempts(effort, run)); }; diff --git a/src/infra/llm/gemini.ts b/src/infra/llm/gemini.ts index 07e722d..136fdbb 100644 --- a/src/infra/llm/gemini.ts +++ b/src/infra/llm/gemini.ts @@ -5,7 +5,7 @@ import { GoogleGenAI, type GenerateContentConfig } from "@google/genai"; import { Future } from "@/libs/future"; import { type Config, type OAuthTokens, type GeminiEffort } from "@/domain/config/config"; import { getAccessToken } from "@/infra/auth/google"; -import { Just, Nothing, type Maybe } from "@/libs/maybe"; +import { Just, Nothing, fromOptional, type Maybe } from "@/libs/maybe"; import { type GenerateContentParams } from "@/domain/llm/router"; import { extractResponse } from "@/domain/llm/response-parser"; import { geminiLevelConfig, geminiBudgetConfig } from "@/domain/llm/effort"; @@ -54,7 +54,7 @@ const generateContentWithApiKey = (apiKey: string, model: string, effort: Maybe< return await ai.models.generateContent({ model, contents: params.prompt, config }); }) .mapRej(toError) - .chain((result) => extractResponse({ provider: "gemini", source: "sdk", value: result })); + .chain((result) => extractResponse({ text: fromOptional(result.text) })); return tryWithEffort(buildAttempts(effort, run)); }; @@ -109,7 +109,7 @@ const generateContentWithOAuth = ( }; }) .mapRej(toError) - .chain((json) => extractResponse({ provider: "gemini", source: "rest", value: json })); + .chain((json) => extractResponse({ text: fromOptional(json.candidates?.[0]?.content?.parts?.[0]?.text) })); return tryWithEffort(buildAttempts(effort, run)); }); diff --git a/src/infra/llm/openai.ts b/src/infra/llm/openai.ts index 23ee146..e3ea654 100644 --- a/src/infra/llm/openai.ts +++ b/src/infra/llm/openai.ts @@ -9,12 +9,31 @@ import { getOpenAIAccessToken } from "@/infra/auth/openai"; import { extractResponse } from "@/domain/llm/response-parser"; import { openaiReasoningParam } from "@/domain/llm/effort"; import { tryWithEffort, type EffortAttempt } from "@/infra/llm/effort-fallback"; -import { type Maybe } from "@/libs/maybe"; +import { fromOptional, type Maybe } from "@/libs/maybe"; type OpenAIConfig = Extract; const toError = (error: unknown): Error => (error instanceof Error ? error : new Error(String(error))); +type StreamBundle = { + response: { + output: Array<{ type: string; content?: Array<{ type: string; text?: string }> }>; + output_text?: string | null; + }; + doneEventText: string; + deltaSnapshotText: string; +}; + +const extractStreamText = (bundle: StreamBundle): Maybe => { + const fromOutput = bundle.response.output + .flatMap((item) => (item.type === "message" ? (item.content ?? []) : [])) + .map((c) => (c.type === "output_text" ? (c.text ?? "") : "")) + .join(""); + + const candidates = [fromOutput, bundle.response.output_text ?? "", bundle.doneEventText, bundle.deltaSnapshotText]; + return fromOptional(candidates.find((v) => v.trim().length > 0)); +}; + const callOpenAIWithApiKey = (authToken: string, model: string, effort: Maybe, params: GenerateContentParams): Future => { const run = (withReasoning: boolean): Future => Future.attemptP(async () => { @@ -29,7 +48,7 @@ const callOpenAIWithApiKey = (authToken: string, model: string, effort: Maybe extractResponse({ provider: "openai", source: "direct", value: response })); + .chain((response) => extractResponse({ text: fromOptional(response.output_text) })); // TODO: the implementation is not good if we need to do attempts to return some response. Remove this attempt and also think in a way to do this type-safe, no helpers and do the calls via SDK instead of REST, that way we can have better types and avoid all this "tryWithEffort" and "EffortAttempt" and "Maybe" and all that. We just need a simple function that tries to call the API with different parameters until it succeeds or runs out of options. const attempts: readonly [EffortAttempt, ...EffortAttempt[]] = @@ -72,7 +91,7 @@ const callOpenAIWithOAuth = (authToken: string, model: string, effort: Maybe extractResponse({ provider: "openai", source: "stream", value: bundle })); + .chain((bundle) => extractResponse({ text: extractStreamText(bundle) })); // TODO: the implementation is not good if we need to do attempts to return some response. Remove this attempt and also think in a way to do this type-safe, no helpers and do the calls via SDK instead of REST, that way we can have better types and avoid all this "tryWithEffort" and "EffortAttempt" and "Maybe" and all that. We just need a simple function that tries to call the API with different parameters until it succeeds or runs out of options. const attempts: readonly [EffortAttempt, ...EffortAttempt[]] = From d574fe6322a7a9a8ac8fa1ebf2c2a24af79a15ad Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 10:56:17 -0300 Subject: [PATCH 13/38] Simplify Anthropic effort handling with always-on adaptive thinking - Remove `anthropicAdaptiveParam`, `anthropicEnabledParam`, and the `BUDGET_BY_EFFORT` map from `src/domain/llm/effort.ts`. - Drop the multi-stage effort fallback (`adaptive`/`enabled`/`off`) and `tryWithEffort` usage in the Anthropic client. - Consolidate API key and setup token request building into a single `buildParams` helper that always enables adaptive thinking and defaults effort to `medium`. - Extract `buildSetupTokenSystem` to compose the Claude Code system prompt with the optional user instruction via `Maybe`. - Replace the generic `toError` with inline error wrapping that surfaces a clearer `Failed to create Anthropic message` message. --- src/domain/llm/effort.ts | 40 +------------ src/infra/llm/anthropic.ts | 118 ++++++++++--------------------------- 2 files changed, 33 insertions(+), 125 deletions(-) diff --git a/src/domain/llm/effort.ts b/src/domain/llm/effort.ts index efc2313..2b0e880 100644 --- a/src/domain/llm/effort.ts +++ b/src/domain/llm/effort.ts @@ -1,13 +1,4 @@ -export { - openaiReasoningParam, - anthropicAdaptiveParam, - anthropicEnabledParam, - geminiLevelConfig, - geminiBudgetConfig, - seedProviderConfig, - withModel, - selectEffortForProvider -}; +export { openaiReasoningParam, geminiLevelConfig, geminiBudgetConfig, seedProviderConfig, withModel, selectEffortForProvider }; import { type ThinkingConfig, ThinkingLevel } from "@google/genai"; import { type Future } from "@/libs/future"; @@ -17,41 +8,12 @@ import { selectOpenAIEffort, selectAnthropicEffort, selectGeminiEffort } from "@ import { absurd } from "@/libs/types"; import type OpenAI from "openai"; -import type Anthropic from "@anthropic-ai/sdk"; const openaiReasoningParam = (effort: Maybe): { reasoning: OpenAI.Reasoning } | undefined => effort.maybe<{ reasoning: OpenAI.Reasoning } | undefined>(undefined, (e) => ({ reasoning: { effort: e } })); -const anthropicAdaptiveParam = ( - effort: Maybe -): { thinking: Anthropic.ThinkingConfigAdaptive; output_config: Anthropic.OutputConfig } | undefined => - effort.maybe<{ thinking: Anthropic.ThinkingConfigAdaptive; output_config: Anthropic.OutputConfig } | undefined>(undefined, (e) => ({ - thinking: { type: "adaptive" }, - output_config: { effort: e } - })); - -const BUDGET_BY_EFFORT: Record = { - low: 1024, - medium: 4096, - high: 16384, - xhigh: 20480, - max: 24576 -}; - -const anthropicEnabledParam = ( - effort: Maybe, - baseMaxTokens: number -): { thinking: Anthropic.ThinkingConfigEnabled; max_tokens: number } | undefined => - effort.maybe<{ thinking: Anthropic.ThinkingConfigEnabled; max_tokens: number } | undefined>(undefined, (e) => { - const budget = BUDGET_BY_EFFORT[e]; - return { - thinking: { type: "enabled", budget_tokens: budget }, - max_tokens: Math.max(baseMaxTokens, budget + 1024) - }; - }); - const LEVEL_MAP: Record = { MINIMAL: ThinkingLevel.MINIMAL, LOW: ThinkingLevel.LOW, diff --git a/src/infra/llm/anthropic.ts b/src/infra/llm/anthropic.ts index 19ab2e3..eb868d6 100644 --- a/src/infra/llm/anthropic.ts +++ b/src/infra/llm/anthropic.ts @@ -8,20 +8,14 @@ import { Future } from "@/libs/future"; import { anthropicOAuthHeaders, CLAUDE_CODE_SYSTEM_PROMPT } from "@/infra/auth/anthropic"; import { absurd } from "@/libs/types"; import { extractResponse } from "@/domain/llm/response-parser"; -import { anthropicAdaptiveParam, anthropicEnabledParam } from "@/domain/llm/effort"; -import { tryWithEffort, type EffortAttempt } from "@/infra/llm/effort-fallback"; -import { Just, type Maybe } from "@/libs/maybe"; +import { Just, fromOptional, type Maybe } from "@/libs/maybe"; type AnthropicConfig = Extract; - type TextBlock = { type: "text"; text: string }; - -type Stage = "adaptive" | "enabled" | "off"; +type SystemParam = NonNullable; const BASE_MAX_TOKENS = 4096; - -// TODO: We really need this? -const toError = (error: unknown): Error => (error instanceof Error ? error : new Error(String(error))); +const claudeCodeBlock: TextBlock = { type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }; const extractAnthropicText = (content: Array<{ type: string; text?: string }>): string => content @@ -29,98 +23,50 @@ const extractAnthropicText = (content: Array<{ type: string; text?: string }>): .map((b) => b.text) .join(""); -const buildApiKeyParams = ( +const buildParams = ( model: string, + system: Maybe, effort: Maybe, - params: GenerateContentParams, - stage: Stage -): Anthropic.MessageCreateParamsNonStreaming => { - const base: Anthropic.MessageCreateParamsNonStreaming = { - model, - max_tokens: BASE_MAX_TOKENS, - ...(params.systemInstruction !== undefined ? { system: params.systemInstruction } : {}), - messages: [{ role: "user", content: params.prompt }] - }; - - // TODO: That's is interesting. These both if's are wrong. We don't want the adaptive. and we don't want to verify if the effort is enabled. The effort should be ever enabled. If any effort level is set, use the "medium" or "high" as default. - if (stage === "adaptive") { - const adaptive = anthropicAdaptiveParam(effort); - return adaptive ? { ...base, ...adaptive } : base; - } - if (stage === "enabled") { - const enabled = anthropicEnabledParam(effort, BASE_MAX_TOKENS); - return enabled ? { ...base, thinking: enabled.thinking, max_tokens: enabled.max_tokens } : base; - } - return base; -}; - -const buildSetupTokenParams = ( - model: string, - effort: Maybe, - params: GenerateContentParams, - stage: Stage + params: GenerateContentParams ): Anthropic.MessageCreateParamsNonStreaming => { - const systemBlocks: TextBlock[] = [{ type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }]; - if (params.systemInstruction !== undefined) { - systemBlocks.push({ type: "text", text: params.systemInstruction }); - } - - const base: Anthropic.MessageCreateParamsNonStreaming = { + const core: Anthropic.MessageCreateParamsNonStreaming = { model, max_tokens: BASE_MAX_TOKENS, - system: systemBlocks, - messages: [{ role: "user", content: params.prompt }] + messages: [{ role: "user", content: params.prompt }], + thinking: { type: "adaptive" }, + output_config: { effort: effort.withDefault("medium") } }; - - if (stage === "adaptive") { - const adaptive = anthropicAdaptiveParam(effort); - return adaptive ? { ...base, ...adaptive } : base; - } - if (stage === "enabled") { - const enabled = anthropicEnabledParam(effort, BASE_MAX_TOKENS); - return enabled ? { ...base, thinking: enabled.thinking, max_tokens: enabled.max_tokens } : base; - } - return base; + return system.maybe(core, (s) => ({ ...core, system: s })); }; -const callAnthropicWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => { - const run = (stage: Stage): Future => - Future.attemptP(async () => { - const client = new Anthropic({ apiKey }); - return await client.messages.create(buildApiKeyParams(model, effort, params, stage)); - }) - .mapRej(toError) - .chain((response) => extractResponse({ text: Just(extractAnthropicText(response.content)) })); +const buildSetupTokenSystem = (instruction: Maybe): SystemParam => + instruction.maybe([claudeCodeBlock], (text) => [claudeCodeBlock, { type: "text", text }]); - return tryWithEffort(buildAttempts(effort, run)); -}; +const callAnthropicWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => + Future.attemptP(async () => { + const client = new Anthropic({ apiKey }); + return await client.messages.create(buildParams(model, fromOptional(params.systemInstruction), effort, params)); + }) + .mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`)) + .chain((response) => extractResponse({ text: Just(extractAnthropicText(response.content)) })); const callAnthropicWithSetupToken = ( authToken: string, model: string, effort: Maybe, params: GenerateContentParams -): Future => { - const run = (stage: Stage): Future => - Future.attemptP(async () => { - const client = new Anthropic({ - apiKey: null, - authToken, - defaultHeaders: anthropicOAuthHeaders() - }); - return await client.messages.create(buildSetupTokenParams(model, effort, params, stage)); - }) - .mapRej(toError) - .chain((response) => extractResponse({ text: Just(extractAnthropicText(response.content)) })); - - return tryWithEffort(buildAttempts(effort, run)); -}; - -const buildAttempts = ( - effort: Maybe, - run: (stage: Stage) => Future -): readonly [EffortAttempt, ...EffortAttempt[]] => - anthropicAdaptiveParam(effort) !== undefined ? [() => run("adaptive"), () => run("enabled"), () => run("off")] : [() => run("off")]; +): Future => + Future.attemptP(async () => { + const client = new Anthropic({ + apiKey: null, + authToken, + defaultHeaders: anthropicOAuthHeaders() + }); + const system = Just(buildSetupTokenSystem(fromOptional(params.systemInstruction))); + return await client.messages.create(buildParams(model, system, effort, params)); + }) + .mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`)) + .chain((response) => extractResponse({ text: Just(extractAnthropicText(response.content)) })); const generateContentWithAnthropic = (config: AnthropicConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { From e451d75988154f17a1db94e07c32b48da6e1ea72 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 11:10:49 -0300 Subject: [PATCH 14/38] Refactor Anthropic client to use native SDK types and raise token limit - Replace custom `TextBlock` type with `Anthropic.ContentBlock` and `Anthropic.TextBlock` in `extractAnthropicText`. - Switch `buildSetupTokenSystem` to return `Anthropic.TextBlockParam[]` and inline the Claude Code system block. - Drop the standalone `claudeCodeBlock` constant. - Increase `BASE_MAX_TOKENS` from 4096 to 16384. --- src/infra/llm/anthropic.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/infra/llm/anthropic.ts b/src/infra/llm/anthropic.ts index eb868d6..4aba056 100644 --- a/src/infra/llm/anthropic.ts +++ b/src/infra/llm/anthropic.ts @@ -11,15 +11,13 @@ import { extractResponse } from "@/domain/llm/response-parser"; import { Just, fromOptional, type Maybe } from "@/libs/maybe"; type AnthropicConfig = Extract; -type TextBlock = { type: "text"; text: string }; type SystemParam = NonNullable; -const BASE_MAX_TOKENS = 4096; -const claudeCodeBlock: TextBlock = { type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }; +const BASE_MAX_TOKENS = 16384; -const extractAnthropicText = (content: Array<{ type: string; text?: string }>): string => +const extractAnthropicText = (content: Anthropic.ContentBlock[]): string => content - .filter((b): b is TextBlock => b.type === "text" && typeof b.text === "string") + .filter((b): b is Anthropic.TextBlock => b.type === "text") .map((b) => b.text) .join(""); @@ -40,7 +38,10 @@ const buildParams = ( }; const buildSetupTokenSystem = (instruction: Maybe): SystemParam => - instruction.maybe([claudeCodeBlock], (text) => [claudeCodeBlock, { type: "text", text }]); + instruction.maybe( + [{ type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }], + (text) => [{ type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }, { type: "text", text }] + ); const callAnthropicWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => Future.attemptP(async () => { From 0f9a3c6990a8d6d0eba075f58ae887998bdbef1f Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 11:37:21 -0300 Subject: [PATCH 15/38] Simplify LLM effort handling and remove fallback retries - Remove `effort-fallback` module and its `tryWithEffort` retry mechanism. - Add shared `unsupportedAuth` helper in `domain/llm/auth-error` for consistent rejection of unsupported auth methods. - Simplify Gemini integration to use a single `thinkingLevel` config with a `MEDIUM` default, dropping multi-stage attempts and budget-based fallbacks. - Simplify OpenAI integration to build request params directly via SDK types, replacing reasoning fallback attempts with a single call. - Remove now-unused `openaiReasoningParam`, `geminiLevelConfig`, and `geminiBudgetConfig` from `domain/llm/effort`. - Use `absurd` for exhaustive auth method handling in Anthropic, Gemini, and OpenAI providers. - Standardize error messages for failed Gemini and OpenAI requests and introduce a shared `BASE_MAX_TOKENS` cap. --- src/domain/llm/auth-error.ts | 7 ++ src/domain/llm/effort.ts | 36 +------ src/infra/llm/anthropic.ts | 11 ++- src/infra/llm/effort-fallback.ts | 25 ----- src/infra/llm/gemini.ts | 129 ++++++++++--------------- src/infra/llm/openai.ts | 155 ++++++++++++++----------------- 6 files changed, 135 insertions(+), 228 deletions(-) create mode 100644 src/domain/llm/auth-error.ts delete mode 100644 src/infra/llm/effort-fallback.ts diff --git a/src/domain/llm/auth-error.ts b/src/domain/llm/auth-error.ts new file mode 100644 index 0000000..4908768 --- /dev/null +++ b/src/domain/llm/auth-error.ts @@ -0,0 +1,7 @@ +export { unsupportedAuth }; + +import { Future } from "@/libs/future"; +import { type AuthMethod, type ProviderConfig } from "@/domain/config/config"; + +const unsupportedAuth = (provider: ProviderConfig["provider"], authType: AuthMethod): Future => + Future.reject(new Error(`Unsupported auth method for ${provider}: ${authType}`)); diff --git a/src/domain/llm/effort.ts b/src/domain/llm/effort.ts index 2b0e880..6db50ae 100644 --- a/src/domain/llm/effort.ts +++ b/src/domain/llm/effort.ts @@ -1,43 +1,11 @@ -export { openaiReasoningParam, geminiLevelConfig, geminiBudgetConfig, seedProviderConfig, withModel, selectEffortForProvider }; +export { seedProviderConfig, withModel, selectEffortForProvider }; -import { type ThinkingConfig, ThinkingLevel } from "@google/genai"; import { type Future } from "@/libs/future"; -import { Nothing, type Maybe } from "@/libs/maybe"; import { type ProviderConfig, type OpenAIEffort, type AnthropicEffort, type GeminiEffort } from "@/domain/config/config"; +import { Nothing } from "@/libs/maybe"; import { selectOpenAIEffort, selectAnthropicEffort, selectGeminiEffort } from "@/infra/ui/effort-picker"; import { absurd } from "@/libs/types"; -import type OpenAI from "openai"; - -const openaiReasoningParam = (effort: Maybe): { reasoning: OpenAI.Reasoning } | undefined => - effort.maybe<{ reasoning: OpenAI.Reasoning } | undefined>(undefined, (e) => ({ - reasoning: { effort: e } - })); - -const LEVEL_MAP: Record = { - MINIMAL: ThinkingLevel.MINIMAL, - LOW: ThinkingLevel.LOW, - MEDIUM: ThinkingLevel.MEDIUM, - HIGH: ThinkingLevel.HIGH -}; - -const geminiLevelConfig = (effort: Maybe): { thinkingConfig: ThinkingConfig } | undefined => - effort.maybe<{ thinkingConfig: ThinkingConfig } | undefined>(undefined, (e) => ({ - thinkingConfig: { thinkingLevel: LEVEL_MAP[e] } - })); - -const BUDGET_BY_LEVEL: Record = { - MINIMAL: 128, - LOW: 512, - MEDIUM: 2048, - HIGH: 8192 -}; - -const geminiBudgetConfig = (effort: Maybe): { thinkingConfig: ThinkingConfig } | undefined => - effort.maybe<{ thinkingConfig: ThinkingConfig } | undefined>(undefined, (e) => ({ - thinkingConfig: { thinkingBudget: BUDGET_BY_LEVEL[e] } - })); - const seedProviderConfig = (provider: ProviderConfig["provider"], model: string, auth_method: ProviderConfig["auth_method"]): ProviderConfig => { switch (provider) { case "openai": diff --git a/src/infra/llm/anthropic.ts b/src/infra/llm/anthropic.ts index 4aba056..dabb906 100644 --- a/src/infra/llm/anthropic.ts +++ b/src/infra/llm/anthropic.ts @@ -8,6 +8,7 @@ import { Future } from "@/libs/future"; import { anthropicOAuthHeaders, CLAUDE_CODE_SYSTEM_PROMPT } from "@/infra/auth/anthropic"; import { absurd } from "@/libs/types"; import { extractResponse } from "@/domain/llm/response-parser"; +import { unsupportedAuth } from "@/domain/llm/auth-error"; import { Just, fromOptional, type Maybe } from "@/libs/maybe"; type AnthropicConfig = Extract; @@ -38,10 +39,10 @@ const buildParams = ( }; const buildSetupTokenSystem = (instruction: Maybe): SystemParam => - instruction.maybe( - [{ type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }], - (text) => [{ type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }, { type: "text", text }] - ); + instruction.maybe([{ type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }], (text) => [ + { type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }, + { type: "text", text } + ]); const callAnthropicWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => Future.attemptP(async () => { @@ -77,7 +78,7 @@ const generateContentWithAnthropic = (config: AnthropicConfig, params: GenerateC return callAnthropicWithSetupToken(config.auth_method.content, config.model, config.effort, params); case "google_oauth": case "openai_oauth": - return Future.reject(new Error(`Unsupported auth method for Anthropic: ${config.auth_method.type}`)); + return unsupportedAuth("anthropic", config.auth_method.type); default: return absurd(config.auth_method, "AuthMethod"); } diff --git a/src/infra/llm/effort-fallback.ts b/src/infra/llm/effort-fallback.ts deleted file mode 100644 index 1c792d6..0000000 --- a/src/infra/llm/effort-fallback.ts +++ /dev/null @@ -1,25 +0,0 @@ -// TODO: I don't know what of these is the worst. We need to review all of this and probably change the function signatures to something more simple, instead of doing this complex thing with "Maybe" and "Future" and "EffortAttempt" and all that. We just need a simple function that takes a list of attempts and tries them one by one until it succeeds or runs out of attempts. That's it. No need for all this complexity. -export { tryWithEffort, type EffortAttempt }; - -import { Future } from "@/libs/future"; - -type EffortAttempt = () => Future; - -const EFFORT_FIELD_RE = /reasoning|thinking|thinking_config|output_config|budget_tokens|thinkingconfig|thinkinglevel|thinkingbudget/i; -const BAD_REQUEST_RE = /(\b400\b|invalid_request|unsupported_parameter|bad_request|invalid_parameter)/i; - -const isEffortRejection = (err: Error): boolean => { - const msg = err.message; - return EFFORT_FIELD_RE.test(msg) && BAD_REQUEST_RE.test(msg); -}; - -const tryWithEffort = (attempts: readonly [EffortAttempt, ...EffortAttempt[]]): Future => { - const walk = (fn: EffortAttempt, remaining: readonly EffortAttempt[]): Future => - fn().chainRej((err) => { - const next = remaining[0]; - if (next === undefined || !isEffortRejection(err)) return Future.reject(err); - return walk(next, remaining.slice(1)); - }); - - return walk(attempts[0], attempts.slice(1)); -}; diff --git a/src/infra/llm/gemini.ts b/src/infra/llm/gemini.ts index 136fdbb..dc1b3f1 100644 --- a/src/infra/llm/gemini.ts +++ b/src/infra/llm/gemini.ts @@ -1,23 +1,26 @@ export { type GeminiAuthCredentials, generateContentWithGemini, getAuthCredentials }; -import { GoogleGenAI, type GenerateContentConfig } from "@google/genai"; +import { GoogleGenAI, ThinkingLevel, type Content, type GenerateContentConfig, type GenerateContentResponse, type GenerationConfig } from "@google/genai"; -import { Future } from "@/libs/future"; import { type Config, type OAuthTokens, type GeminiEffort } from "@/domain/config/config"; +import { type GenerateContentParams } from "@/domain/llm/router"; +import { Future } from "@/libs/future"; import { getAccessToken } from "@/infra/auth/google"; import { Just, Nothing, fromOptional, type Maybe } from "@/libs/maybe"; -import { type GenerateContentParams } from "@/domain/llm/router"; import { extractResponse } from "@/domain/llm/response-parser"; -import { geminiLevelConfig, geminiBudgetConfig } from "@/domain/llm/effort"; -import { tryWithEffort, type EffortAttempt } from "@/infra/llm/effort-fallback"; +import { unsupportedAuth } from "@/domain/llm/auth-error"; +import { absurd } from "@/libs/types"; type GeminiConfig = Extract; - type GeminiAuthCredentials = { readonly method: "api_key"; readonly apiKey: string } | { readonly method: "google_oauth"; readonly tokens: OAuthTokens }; -type Stage = "level" | "budget" | "off"; +type OAuthRequestBody = { + contents: Content[]; + systemInstruction?: Content; + generationConfig?: GenerationConfig; +}; -const toError = (error: unknown): Error => (error instanceof Error ? error : new Error(String(error))); +const BASE_MAX_TOKENS = 16384; const getAuthCredentials = (config: Config): Maybe => { if (config.ai.provider !== "gemini") return Nothing(); @@ -32,87 +35,54 @@ const getAuthCredentials = (config: Config): Maybe => { } }; -const buildConfigForStage = (effort: Maybe, params: GenerateContentParams, stage: Stage): GenerateContentConfig => { - const base: GenerateContentConfig = {}; - if (params.systemInstruction !== undefined) base.systemInstruction = params.systemInstruction; - if (stage === "level") Object.assign(base, geminiLevelConfig(effort)); - if (stage === "budget") Object.assign(base, geminiBudgetConfig(effort)); - return base; -}; - -const buildAttempts = ( - effort: Maybe, - run: (stage: Stage) => Future -): readonly [EffortAttempt, ...EffortAttempt[]] => - geminiLevelConfig(effort) !== undefined ? [() => run("level"), () => run("budget"), () => run("off")] : [() => run("off")]; - -const generateContentWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => { - const run = (stage: Stage): Future => - Future.attemptP(async () => { - const ai = new GoogleGenAI({ apiKey }); - const config = buildConfigForStage(effort, params, stage); - return await ai.models.generateContent({ model, contents: params.prompt, config }); - }) - .mapRej(toError) - .chain((result) => extractResponse({ text: fromOptional(result.text) })); - - return tryWithEffort(buildAttempts(effort, run)); +const buildSDKConfig = (effort: Maybe, params: GenerateContentParams): GenerateContentConfig => { + const core: GenerateContentConfig = { + maxOutputTokens: BASE_MAX_TOKENS, + thinkingConfig: { thinkingLevel: effort.withDefault(ThinkingLevel.MEDIUM) } + }; + return fromOptional(params.systemInstruction).maybe(core, (s) => ({ ...core, systemInstruction: s })); }; -const buildOAuthBody = (effort: Maybe, params: GenerateContentParams, stage: Stage): Record => { - const body: Record = { - contents: [{ parts: [{ text: params.prompt }] }] +const buildOAuthBody = (effort: Maybe, params: GenerateContentParams): OAuthRequestBody => { + const core: OAuthRequestBody = { + contents: [{ parts: [{ text: params.prompt }] }], + generationConfig: { maxOutputTokens: BASE_MAX_TOKENS, thinkingConfig: { thinkingLevel: effort.withDefault(ThinkingLevel.MEDIUM) } } }; + return fromOptional(params.systemInstruction).maybe(core, (s) => ({ ...core, systemInstruction: { parts: [{ text: s }] } })); +}; - if (params.systemInstruction !== undefined) { - body["system_instruction"] = { parts: [{ text: params.systemInstruction }] }; - } - - // TODO: maybe we don't need to check any of this, we could have a default set for each. - const levelCfg = stage === "level" ? geminiLevelConfig(effort) : undefined; - const budgetCfg = stage === "budget" ? geminiBudgetConfig(effort) : undefined; - const thinking = levelCfg ?? budgetCfg; - if (thinking) body["generationConfig"] = { thinkingConfig: thinking.thinkingConfig }; - - return body; +const parseOAuthResponse = async (response: Response): Promise => { + if (!response.ok) throw new Error(`Gemini API error (${response.status}): ${await response.text()}`); + return (await response.json()) as GenerateContentResponse; }; +const generateContentWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => + Future.attemptP(async () => { + const ai = new GoogleGenAI({ apiKey }); + return await ai.models.generateContent({ model, contents: params.prompt, config: buildSDKConfig(effort, params) }); + }) + .mapRej((error) => new Error(`Failed to create Gemini content: ${error instanceof Error ? error.message : String(error)}`)) + .chain((result) => extractResponse({ text: fromOptional(result.text) })); + const generateContentWithOAuth = ( tokens: OAuthTokens, model: string, effort: Maybe, params: GenerateContentParams ): Future => - getAccessToken(tokens).chain((accessToken) => { - const run = (stage: Stage): Future => - Future.attemptP(async () => { - const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json" - }, - body: JSON.stringify(buildOAuthBody(effort, params, stage)) - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`Gemini API error (${response.status}): ${errorBody}`); - } - - // TODO: Remove the type-cast and review the types of this, and try to move this to use via SDK; - return (await response.json()) as { - promptFeedback?: unknown; - usageMetadata?: unknown; - candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; - }; - }) - .mapRej(toError) - .chain((json) => extractResponse({ text: fromOptional(json.candidates?.[0]?.content?.parts?.[0]?.text) })); - - return tryWithEffort(buildAttempts(effort, run)); - }); + getAccessToken(tokens).chain((accessToken) => + Future.attemptP(async () => { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; + const response = await fetch(url, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildOAuthBody(effort, params)) + }); + return await parseOAuthResponse(response); + }) + .mapRej((error) => new Error(`Failed to create Gemini content: ${error instanceof Error ? error.message : String(error)}`)) + .chain((json) => extractResponse({ text: fromOptional(json.candidates?.[0]?.content?.parts?.[0]?.text) })) + ); const generateContentWithGemini = (config: GeminiConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { @@ -120,7 +90,10 @@ const generateContentWithGemini = (config: GeminiConfig, params: GenerateContent return generateContentWithApiKey(config.auth_method.content, config.model, config.effort, params); case "google_oauth": return generateContentWithOAuth(config.auth_method.content, config.model, config.effort, params); + case "openai_oauth": + case "anthropic_setup_token": + return unsupportedAuth("gemini", config.auth_method.type); default: - return Future.reject(new Error(`Unsupported auth method for Gemini: ${config.auth_method.type}`)); + return absurd(config.auth_method, "AuthMethod"); } }; diff --git a/src/infra/llm/openai.ts b/src/infra/llm/openai.ts index e3ea654..5185bbc 100644 --- a/src/infra/llm/openai.ts +++ b/src/infra/llm/openai.ts @@ -2,121 +2,104 @@ export { generateContentWithOpenAI }; import OpenAI from "openai"; -import { type Config, type OpenAITokens, type OpenAIEffort } from "@/domain/config/config"; +import { type Config, type OpenAIEffort } from "@/domain/config/config"; import { type GenerateContentParams } from "@/domain/llm/router"; import { Future } from "@/libs/future"; import { getOpenAIAccessToken } from "@/infra/auth/openai"; import { extractResponse } from "@/domain/llm/response-parser"; -import { openaiReasoningParam } from "@/domain/llm/effort"; -import { tryWithEffort, type EffortAttempt } from "@/infra/llm/effort-fallback"; +import { unsupportedAuth } from "@/domain/llm/auth-error"; +import { absurd } from "@/libs/types"; import { fromOptional, type Maybe } from "@/libs/maybe"; type OpenAIConfig = Extract; - -const toError = (error: unknown): Error => (error instanceof Error ? error : new Error(String(error))); - type StreamBundle = { - response: { - output: Array<{ type: string; content?: Array<{ type: string; text?: string }> }>; - output_text?: string | null; - }; + response: OpenAI.Responses.Response; doneEventText: string; deltaSnapshotText: string; }; +const BASE_MAX_TOKENS = 16384; + const extractStreamText = (bundle: StreamBundle): Maybe => { const fromOutput = bundle.response.output - .flatMap((item) => (item.type === "message" ? (item.content ?? []) : [])) - .map((c) => (c.type === "output_text" ? (c.text ?? "") : "")) + .flatMap((item) => (item.type === "message" ? item.content : [])) + .map((c) => (c.type === "output_text" ? c.text : "")) .join(""); - const candidates = [fromOutput, bundle.response.output_text ?? "", bundle.doneEventText, bundle.deltaSnapshotText]; + const candidates = [fromOutput, bundle.response.output_text, bundle.doneEventText, bundle.deltaSnapshotText]; return fromOptional(candidates.find((v) => v.trim().length > 0)); }; -const callOpenAIWithApiKey = (authToken: string, model: string, effort: Maybe, params: GenerateContentParams): Future => { - const run = (withReasoning: boolean): Future => - Future.attemptP(async () => { - const client = new OpenAI({ apiKey: authToken }); - const reasoning = withReasoning ? openaiReasoningParam(effort) : undefined; - return await client.responses.create({ - model, - instructions: params.systemInstruction ?? null, - input: params.prompt, - // TODO: we need to avoid use this "{}" object; - ...(reasoning ?? {}) - }); - }) - .mapRej(toError) - .chain((response) => extractResponse({ text: fromOptional(response.output_text) })); - - // TODO: the implementation is not good if we need to do attempts to return some response. Remove this attempt and also think in a way to do this type-safe, no helpers and do the calls via SDK instead of REST, that way we can have better types and avoid all this "tryWithEffort" and "EffortAttempt" and "Maybe" and all that. We just need a simple function that tries to call the API with different parameters until it succeeds or runs out of options. - const attempts: readonly [EffortAttempt, ...EffortAttempt[]] = - openaiReasoningParam(effort) !== undefined ? [() => run(true), () => run(false)] : [() => run(false)]; - - return tryWithEffort(attempts); +const openaiReasoning = (effort: Maybe): Maybe => effort.map((e) => ({ effort: e })); + +const buildApiKeyParams = ( + model: string, + effort: Maybe, + params: GenerateContentParams +): OpenAI.Responses.ResponseCreateParamsNonStreaming => { + const core: OpenAI.Responses.ResponseCreateParamsNonStreaming = { + model, + instructions: params.systemInstruction ?? null, + input: params.prompt, + max_output_tokens: BASE_MAX_TOKENS + }; + return openaiReasoning(effort).maybe(core, (r) => ({ ...core, reasoning: r })); }; -const callOpenAIWithOAuth = (authToken: string, model: string, effort: Maybe, params: GenerateContentParams): Future => { - const run = (withReasoning: boolean): Future => - Future.attemptP(async () => { - const client = new OpenAI({ - baseURL: "https://chatgpt.com/backend-api/codex", - apiKey: authToken - }); - - const reasoning = withReasoning ? openaiReasoningParam(effort) : undefined; - - const stream = client.responses.stream({ - model, - instructions: params.systemInstruction ?? "", - input: [{ role: "user", content: params.prompt }], - store: false, - // TODO: we need to avoid use this "{}" object; - ...(reasoning ?? {}) - }); - - let deltaSnapshotText = ""; - let doneEventText = ""; - - stream.on("response.output_text.delta", (event) => { - deltaSnapshotText = event.snapshot; - }); - - stream.on("response.output_text.done", (event) => { - doneEventText = event.text; - }); - - const response = await stream.finalResponse(); - return { response, doneEventText, deltaSnapshotText }; - }) - .mapRej(toError) - .chain((bundle) => extractResponse({ text: extractStreamText(bundle) })); - - // TODO: the implementation is not good if we need to do attempts to return some response. Remove this attempt and also think in a way to do this type-safe, no helpers and do the calls via SDK instead of REST, that way we can have better types and avoid all this "tryWithEffort" and "EffortAttempt" and "Maybe" and all that. We just need a simple function that tries to call the API with different parameters until it succeeds or runs out of options. - const attempts: readonly [EffortAttempt, ...EffortAttempt[]] = - openaiReasoningParam(effort) !== undefined ? [() => run(true), () => run(false)] : [() => run(false)]; - - return tryWithEffort(attempts); +const buildOAuthParams = (model: string, effort: Maybe, params: GenerateContentParams): OpenAI.Responses.ResponseCreateParamsStreaming => { + const core: OpenAI.Responses.ResponseCreateParamsStreaming = { + model, + instructions: params.systemInstruction ?? "", + input: [{ role: "user", content: params.prompt }], + store: false, + stream: true, + max_output_tokens: BASE_MAX_TOKENS + }; + return openaiReasoning(effort).maybe(core, (r) => ({ ...core, reasoning: r })); }; -const generateContentWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => - callOpenAIWithApiKey(apiKey, model, effort, params); +const callOpenAIWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => + Future.attemptP(async () => { + const client = new OpenAI({ apiKey }); + return await client.responses.create(buildApiKeyParams(model, effort, params)); + }) + .mapRej((error) => new Error(`Failed to create OpenAI response: ${error instanceof Error ? error.message : String(error)}`)) + .chain((response) => extractResponse({ text: fromOptional(response.output_text) })); -const generateContentWithOAuth = ( - tokens: OpenAITokens, - model: string, - effort: Maybe, - params: GenerateContentParams -): Future => getOpenAIAccessToken(tokens).chain((accessToken) => callOpenAIWithOAuth(accessToken, model, effort, params)); +const callOpenAIWithOAuth = (authToken: string, model: string, effort: Maybe, params: GenerateContentParams): Future => + Future.attemptP(async () => { + const client = new OpenAI({ baseURL: "https://chatgpt.com/backend-api/codex", apiKey: authToken }); + const stream = client.responses.stream(buildOAuthParams(model, effort, params)); + + let deltaSnapshotText = ""; + let doneEventText = ""; + + stream.on("response.output_text.delta", (event) => { + deltaSnapshotText = event.snapshot; + }); + + stream.on("response.output_text.done", (event) => { + doneEventText = event.text; + }); + + const response = await stream.finalResponse(); + return { response, doneEventText, deltaSnapshotText }; + }) + .mapRej((error) => new Error(`Failed to create OpenAI response: ${error instanceof Error ? error.message : String(error)}`)) + .chain((bundle) => extractResponse({ text: extractStreamText(bundle) })); const generateContentWithOpenAI = (config: OpenAIConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { case "api_key": - return generateContentWithApiKey(config.auth_method.content, config.model, config.effort, params); + return callOpenAIWithApiKey(config.auth_method.content, config.model, config.effort, params); case "openai_oauth": - return generateContentWithOAuth(config.auth_method.content, config.model, config.effort, params); + return getOpenAIAccessToken(config.auth_method.content).chain((accessToken) => + callOpenAIWithOAuth(accessToken, config.model, config.effort, params) + ); + case "google_oauth": + case "anthropic_setup_token": + return unsupportedAuth("openai", config.auth_method.type); default: - return Future.reject(new Error(`Unsupported auth method for OpenAI: ${config.auth_method.type}`)); + return absurd(config.auth_method, "AuthMethod"); } }; From 29ce9c2ecbfec4b80dc388a43ec0172528942822 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 11:46:39 -0300 Subject: [PATCH 16/38] Add doctor timing output and improve base branch resolution - Measure and display elapsed time in `doctor` run output. - Pass `elapsedMs` through `renderTable` to report duration on success. - Strip `refs/remotes//` prefix in `normalizeBranchRef`. - Fall back to default remote branch in `getBaseBranch` when the resolved base matches the current branch. --- src/cli/doctor.ts | 7 ++++--- src/infra/git/repo.ts | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 5bf9539..6b853a3 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -24,11 +24,12 @@ class Doctor { } run(): Future { + const start = performance.now(); return this.checkOAuthCredentials().chain((oauthRow) => this.checkConfig().chain((configRows) => this.checkGitContext().map((gitRows) => { const rows: CheckRow[] = [this.checkRuntime(), this.checkPlatform(), oauthRow, ...configRows, ...gitRows]; - this.renderTable(rows); + this.renderTable(rows, performance.now() - start); }) ) ); @@ -114,7 +115,7 @@ class Doctor { }).map(({ branch, base, pr: prLookup }): CheckRow[] => [renderBranchRow(branch), renderBaseRow(base), renderPrRow(prLookup)]); } - private renderTable(rows: CheckRow[]): void { + private renderTable(rows: CheckRow[], elapsedMs: number): void { const table = new Table({ head: [color.cyan("Check"), color.cyan("Status"), color.cyan("Info")], colWidths: [20, 15, 40] @@ -130,7 +131,7 @@ class Doctor { if (!hasConfig) { process.stdout.write(color.yellow("! Please run 'commit-tools setup' to configure your API key.\n\n")); } else { - process.stdout.write(color.green("System is ready to generate commits!\n\n")); + process.stdout.write(color.green(`System is ready to generate commits! Done in ${(elapsedMs / 1000).toFixed(2)}s`)); } } } diff --git a/src/infra/git/repo.ts b/src/infra/git/repo.ts index bc62865..7e11c09 100644 --- a/src/infra/git/repo.ts +++ b/src/infra/git/repo.ts @@ -145,7 +145,7 @@ const parseCreatedFrom = (subject: string): Result => { return source && source !== "HEAD" ? Success(source) : Failure({ type: "reflog-not-creation", subject }); }; -const normalizeBranchRef = (ref: string): string => ref.replace(/^refs\/heads\//, ""); +const normalizeBranchRef = (ref: string): string => ref.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\/[^/]+\//, ""); const parseBaseFromReflog = (stdout: string): Result => oldestReflogSubject(stdout).chain(parseCreatedFrom).map(normalizeBranchRef); @@ -185,7 +185,7 @@ const getBaseBranch = (): Future> => return absurd(err, "BaseLookupError"); } }, - (base) => Future.resolve>(Just(base)) + (base) => (base === branch ? getDefaultRemoteBranch() : Future.resolve>(Just(base))) ) ) ); From 9d89ded7d497dc9aaa6cbbaa09caa1f70078f50c Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 12:51:56 -0300 Subject: [PATCH 17/38] Remove hardcoded LLM output token limits - Stop sending `maxOutputTokens` for Gemini SDK and OAuth requests. - Stop sending `max_output_tokens` for OpenAI API key and OAuth requests. - Guard OpenAI stream text extraction against non-string candidates before trimming. --- src/infra/llm/gemini.ts | 5 +---- src/infra/llm/openai.ts | 10 +++------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/infra/llm/gemini.ts b/src/infra/llm/gemini.ts index dc1b3f1..848ac99 100644 --- a/src/infra/llm/gemini.ts +++ b/src/infra/llm/gemini.ts @@ -20,8 +20,6 @@ type OAuthRequestBody = { generationConfig?: GenerationConfig; }; -const BASE_MAX_TOKENS = 16384; - const getAuthCredentials = (config: Config): Maybe => { if (config.ai.provider !== "gemini") return Nothing(); @@ -37,7 +35,6 @@ const getAuthCredentials = (config: Config): Maybe => { const buildSDKConfig = (effort: Maybe, params: GenerateContentParams): GenerateContentConfig => { const core: GenerateContentConfig = { - maxOutputTokens: BASE_MAX_TOKENS, thinkingConfig: { thinkingLevel: effort.withDefault(ThinkingLevel.MEDIUM) } }; return fromOptional(params.systemInstruction).maybe(core, (s) => ({ ...core, systemInstruction: s })); @@ -46,7 +43,7 @@ const buildSDKConfig = (effort: Maybe, params: GenerateContentPara const buildOAuthBody = (effort: Maybe, params: GenerateContentParams): OAuthRequestBody => { const core: OAuthRequestBody = { contents: [{ parts: [{ text: params.prompt }] }], - generationConfig: { maxOutputTokens: BASE_MAX_TOKENS, thinkingConfig: { thinkingLevel: effort.withDefault(ThinkingLevel.MEDIUM) } } + generationConfig: { thinkingConfig: { thinkingLevel: effort.withDefault(ThinkingLevel.MEDIUM) } } }; return fromOptional(params.systemInstruction).maybe(core, (s) => ({ ...core, systemInstruction: { parts: [{ text: s }] } })); }; diff --git a/src/infra/llm/openai.ts b/src/infra/llm/openai.ts index 5185bbc..ac4b568 100644 --- a/src/infra/llm/openai.ts +++ b/src/infra/llm/openai.ts @@ -18,8 +18,6 @@ type StreamBundle = { deltaSnapshotText: string; }; -const BASE_MAX_TOKENS = 16384; - const extractStreamText = (bundle: StreamBundle): Maybe => { const fromOutput = bundle.response.output .flatMap((item) => (item.type === "message" ? item.content : [])) @@ -27,7 +25,7 @@ const extractStreamText = (bundle: StreamBundle): Maybe => { .join(""); const candidates = [fromOutput, bundle.response.output_text, bundle.doneEventText, bundle.deltaSnapshotText]; - return fromOptional(candidates.find((v) => v.trim().length > 0)); + return fromOptional(candidates.find((v): v is string => typeof v === "string" && v.trim().length > 0)); }; const openaiReasoning = (effort: Maybe): Maybe => effort.map((e) => ({ effort: e })); @@ -40,8 +38,7 @@ const buildApiKeyParams = ( const core: OpenAI.Responses.ResponseCreateParamsNonStreaming = { model, instructions: params.systemInstruction ?? null, - input: params.prompt, - max_output_tokens: BASE_MAX_TOKENS + input: params.prompt }; return openaiReasoning(effort).maybe(core, (r) => ({ ...core, reasoning: r })); }; @@ -52,8 +49,7 @@ const buildOAuthParams = (model: string, effort: Maybe, params: Ge instructions: params.systemInstruction ?? "", input: [{ role: "user", content: params.prompt }], store: false, - stream: true, - max_output_tokens: BASE_MAX_TOKENS + stream: true }; return openaiReasoning(effort).maybe(core, (r) => ({ ...core, reasoning: r })); }; From 1c1311069b4f586d66e8d035b6868d7760a46161 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 13:31:25 -0300 Subject: [PATCH 18/38] Add `MVar` and `Queue` concurrency primitives - Add immutable `Queue` with amortised O(1) `enqueue` and `dequeue` using front/back list pair. - Add mutable `MQueue` wrapper around the immutable `Queue`. - Add `MVar` for state-based coordination of concurrent operations, blocking on full or empty state. - Support blocking `put`, `take`, `modify`, `modify_`, and `read` operations with FIFO fairness. - Provide non-blocking `tryPut`, `tryTake`, and `tryRead` variants returning success or `Maybe` results. --- src/libs/mvar.ts | 212 ++++++++++++++++++++++++++++++++++++++++++++++ src/libs/queue.ts | 96 +++++++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 src/libs/mvar.ts create mode 100644 src/libs/queue.ts diff --git a/src/libs/mvar.ts b/src/libs/mvar.ts new file mode 100644 index 0000000..d1cfbfc --- /dev/null +++ b/src/libs/mvar.ts @@ -0,0 +1,212 @@ +export { MVar }; + +import { MQueue } from "@/libs/queue"; +import { Maybe, Just, Nothing } from "@/libs/maybe"; + +type Value = + | { tag: "empty" } // + | { tag: "full"; value: T } + | { tag: "in_use" }; + +/* + A mutable variable that can be full or empty. + Use state-based coordination of concurrent operations. e.g. block until a condition is true/false. + MVars are fair. Puts and takes are resolved in the order in which they were made. + + Trying to put on a full MVar blocks until the MVar is empty. + Trying to take on an empty MVar blocks until the MVar is full. +*/ +class MVar { + // Queued writes and modifications are resolved in the order in which they arrive. + private waitingEmpty: MQueue<[A, () => void]> = MQueue.new(); + private waitingFull: MQueue<(v: A) => Promise> = MQueue.new(); + private value: Value; + + private constructor(initial: Maybe) { + this.value = initial.maybe>({ tag: "empty" }, value => ({ tag: "full", value })); + } + + static new(v: A): MVar { + return new MVar(Just(v)); + } + + static newEmpty(): MVar { + return new MVar(Nothing()); + } + + private _unblockWaitingFull(x: A): void { + const r = this.waitingFull.dequeue(); + if (r instanceof Just) { + this.value = { tag: "in_use" }; + r.value(x); // we trigger the promise, but don't wait. + } else { + this.value = { tag: "full", value: x }; + } + } + + // Put a value into an empty MVar. + // If the MVar is full, it blocks until it becomes empty. + put(v: A): Promise { + const enqueue = () => + new Promise(resolve => { + this.waitingEmpty.enqueue([ + v, + () => { + this._unblockWaitingFull(v); + resolve(); + }, + ]); + }); + + switch (this.value.tag) { + case "empty": + this._unblockWaitingFull(v); + return Promise.resolve(); + case "full": + return enqueue(); + case "in_use": + return enqueue(); + default: + return this.value satisfies never; + } + } + + // Like put, but doesn't block. + // Returns whether the put was successful or not. + tryPut(v: A): boolean { + switch (this.value.tag) { + case "empty": + this._unblockWaitingFull(v); + return true; + case "full": + return false; + case "in_use": + return false; + default: + return this.value satisfies never; + } + } + + private _unblockWaitingEmpty(): void { + this.value = { tag: "in_use" }; + // is there something waiting for it to be emtpy? + const r = this.waitingEmpty.dequeue(); + + if (r instanceof Nothing) { + // nope, let's just mark it as empty + this.value = { tag: "empty" }; + } else { + // yes, there is a new value here. + const [newVal, trigger] = r.value; + trigger(); + this._unblockWaitingFull(newVal); + } + } + + // Take the value in the MVar, leaving it empty. + // If the MVar is empty, it blocks until it becomes full. + take(): Promise { + const enqueue = () => + new Promise(resolve => { + this.waitingFull.enqueue(v => { + this._unblockWaitingEmpty(); + resolve(v); + return Promise.resolve(); + }); + }); + + switch (this.value.tag) { + case "empty": + return enqueue(); + case "full": { + const value = this.value.value; + this._unblockWaitingEmpty(); + return Promise.resolve(value); + } + case "in_use": + return enqueue(); + default: + return this.value satisfies never; + } + } + + // Like take, but doesn't block. + // Returns whether the take was successful or not. + tryTake(): Maybe { + switch (this.value.tag) { + case "empty": + return Nothing(); + case "full": { + const value = this.value.value; + this._unblockWaitingEmpty(); + return Just(value); + } + case "in_use": + return Nothing(); + default: + return this.value satisfies never; + } + } + + // Modify the value in the MVar. Allows returning a value in the computation too. + // If the MVar is empty, it blocks until it becomes full. + async modify(f: (v: A) => Promise<[A, B]>): Promise { + const resume = async (value: A): Promise => { + try { + this.value = { tag: "in_use" }; + const [v, r] = await f(value); + this._unblockWaitingFull(v); + return r; + } catch (e) { + this._unblockWaitingFull(value); // put value back + return Promise.reject(e); + } + }; + + const enqueue = (): Promise => + new Promise((resolve, reject) => this.waitingFull.enqueue(value => resume(value).then(resolve).catch(reject))); + + switch (this.value.tag) { + case "empty": + return enqueue(); + case "full": + return resume(this.value.value); + case "in_use": + return enqueue(); + default: + return this.value satisfies never; + } + } + + /* Like 'modify' but without returning a value. + */ + async modify_(f: (v: A) => Promise): Promise { + return this.modify(v => f(v).then(x => [x, undefined])); + } + + // Get the value of the MVar without removing it. + async read(): Promise { + const v = this.tryRead(); + if (v instanceof Just) { + return v.value; + } + return await this.modify(async v => [v, v]); + } + + // Like read, but doesn't block. + // Returns the value if the MVar was full. + tryRead(): Maybe { + switch (this.value.tag) { + case "empty": + return Nothing(); + case "full": { + const value = this.value.value; + return Just(value); + } + case "in_use": + return Nothing(); + default: + return this.value satisfies never; + } + } +} diff --git a/src/libs/queue.ts b/src/libs/queue.ts new file mode 100644 index 0000000..27f1a14 --- /dev/null +++ b/src/libs/queue.ts @@ -0,0 +1,96 @@ +export { Queue, MQueue }; + +import { List } from "@/libs/list"; +import { Maybe, Just, Nothing } from "@/libs/maybe"; + +// Immutable queue with amortised O(1) push and pop. +class Queue { + private constructor( + public readonly length: number, + private front: List, + private back: List, + ) {} + + static new(): Queue { + return new Queue(0, List.empty(), List.empty()); + } + + static fromArray(xs: T[]): Queue { + let queue = Queue.new(); + for (const x of xs) { + queue = queue.enqueue(x); + } + return queue; + } + + isEmpty(): boolean { + return this.length == 0; + } + + toArray(): T[] { + return this.front.toArray().concat(this.back.toArray().reverse()); + } + + // Add to the back of the queue + enqueue(v: T): Queue { + return new Queue(this.length + 1, this.front, List.cons(v, this.back)); + } + + // Take from the front of the queue + dequeue(): Maybe<[T, Queue]> { + // Here, if the front of the queue is empty we reverse + // the back and make it the front. This is how we achieve + // amortised O(1) time. + const reverseBack = (): Maybe<[T, Queue]> => { + const front = this.back.reverse(); + if (front.isEmpty()) return Nothing(); + const queue = new Queue(this.length, front, List.empty()); + return queue.dequeue(); + }; + + return this.front + .head() + .unwrap(reverseBack, v => Just([v, new Queue(this.length - 1, this.front.tail(), this.back)])); + } +} + +// Mutable queue with amortised O(1) enqueue and dequeue. +class MQueue { + private constructor(private queue: Queue) {} + + static new(): MQueue { + return new MQueue(Queue.new()); + } + + static fromArray(xs: T[]): MQueue { + return new MQueue(Queue.fromArray(xs)); + } + + isEmpty(): boolean { + return this.queue.isEmpty(); + } + + toArray(): T[] { + return this.queue.toArray(); + } + + get length(): number { + return this.queue.length; + } + + // Add to the back of the queue + enqueue(v: T): void { + this.queue = this.queue.enqueue(v); + } + + // Take from the front of the queue + dequeue(): Maybe { + return this.queue.dequeue().unwrap( + () => Nothing(), + ([v, queue]) => { + this.queue = queue; + return Just(v); + }, + ); + } +} From 783feb55da219589662b06300ab1d7b628667f2e Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 13:43:34 -0300 Subject: [PATCH 19/38] Update Gemini to use streaming responses - Update `generateContentWithApiKey` to use the SDK's `generateContentStream` method. - Update `generateContentWithOAuth` to call the `streamGenerateContent` endpoint and parse SSE responses. - Add `accumulateSSEText` and `extractSSEEventText` helpers to handle stream data processing. - Remove redundant provider check in `getAuthCredentials`. --- src/infra/llm/gemini.ts | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/infra/llm/gemini.ts b/src/infra/llm/gemini.ts index 848ac99..d5473d8 100644 --- a/src/infra/llm/gemini.ts +++ b/src/infra/llm/gemini.ts @@ -21,8 +21,6 @@ type OAuthRequestBody = { }; const getAuthCredentials = (config: Config): Maybe => { - if (config.ai.provider !== "gemini") return Nothing(); - switch (config.ai.auth_method.type) { case "google_oauth": return Just({ method: "google_oauth", tokens: config.ai.auth_method.content }); @@ -48,18 +46,43 @@ const buildOAuthBody = (effort: Maybe, params: GenerateContentPara return fromOptional(params.systemInstruction).maybe(core, (s) => ({ ...core, systemInstruction: { parts: [{ text: s }] } })); }; -const parseOAuthResponse = async (response: Response): Promise => { +const extractSSEEventText = (event: string): string => { + const dataLine = event.split("\n").find((l) => l.startsWith("data: ")); + if (!dataLine) return ""; + const json = JSON.parse(dataLine.slice(6)) as GenerateContentResponse; + return json.candidates?.[0]?.content?.parts?.[0]?.text ?? ""; +}; + +const accumulateSSEText = async (response: Response): Promise => { if (!response.ok) throw new Error(`Gemini API error (${response.status}): ${await response.text()}`); - return (await response.json()) as GenerateContentResponse; + if (!response.body) throw new Error("Gemini stream returned no body"); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let text = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split(/\r?\n\r?\n/); + buffer = events.pop() ?? ""; + for (const ev of events) text += extractSSEEventText(ev); + } + return text; }; const generateContentWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => Future.attemptP(async () => { const ai = new GoogleGenAI({ apiKey }); - return await ai.models.generateContent({ model, contents: params.prompt, config: buildSDKConfig(effort, params) }); + const stream = await ai.models.generateContentStream({ model, contents: params.prompt, config: buildSDKConfig(effort, params) }); + let text = ""; + for await (const chunk of stream) { + if (chunk.text) text += chunk.text; + } + return text; }) .mapRej((error) => new Error(`Failed to create Gemini content: ${error instanceof Error ? error.message : String(error)}`)) - .chain((result) => extractResponse({ text: fromOptional(result.text) })); + .chain((text) => extractResponse({ text: fromOptional(text) })); const generateContentWithOAuth = ( tokens: OAuthTokens, @@ -69,16 +92,16 @@ const generateContentWithOAuth = ( ): Future => getAccessToken(tokens).chain((accessToken) => Future.attemptP(async () => { - const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; + const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse`; const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, body: JSON.stringify(buildOAuthBody(effort, params)) }); - return await parseOAuthResponse(response); + return await accumulateSSEText(response); }) .mapRej((error) => new Error(`Failed to create Gemini content: ${error instanceof Error ? error.message : String(error)}`)) - .chain((json) => extractResponse({ text: fromOptional(json.candidates?.[0]?.content?.parts?.[0]?.text) })) + .chain((text) => extractResponse({ text: fromOptional(text) })) ); const generateContentWithGemini = (config: GeminiConfig, params: GenerateContentParams): Future => { From 13f9b5848523e411c49435e805a3137fdbf9315a Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 13:44:33 -0300 Subject: [PATCH 20/38] Use streaming OpenAI responses for all auth methods --- src/infra/llm/openai.ts | 34 +++++++++------------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/src/infra/llm/openai.ts b/src/infra/llm/openai.ts index ac4b568..d34511d 100644 --- a/src/infra/llm/openai.ts +++ b/src/infra/llm/openai.ts @@ -30,20 +30,7 @@ const extractStreamText = (bundle: StreamBundle): Maybe => { const openaiReasoning = (effort: Maybe): Maybe => effort.map((e) => ({ effort: e })); -const buildApiKeyParams = ( - model: string, - effort: Maybe, - params: GenerateContentParams -): OpenAI.Responses.ResponseCreateParamsNonStreaming => { - const core: OpenAI.Responses.ResponseCreateParamsNonStreaming = { - model, - instructions: params.systemInstruction ?? null, - input: params.prompt - }; - return openaiReasoning(effort).maybe(core, (r) => ({ ...core, reasoning: r })); -}; - -const buildOAuthParams = (model: string, effort: Maybe, params: GenerateContentParams): OpenAI.Responses.ResponseCreateParamsStreaming => { +const buildStreamParams = (model: string, effort: Maybe, params: GenerateContentParams): OpenAI.Responses.ResponseCreateParamsStreaming => { const core: OpenAI.Responses.ResponseCreateParamsStreaming = { model, instructions: params.systemInstruction ?? "", @@ -54,18 +41,9 @@ const buildOAuthParams = (model: string, effort: Maybe, params: Ge return openaiReasoning(effort).maybe(core, (r) => ({ ...core, reasoning: r })); }; -const callOpenAIWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => +const callOpenAIStream = (client: OpenAI, model: string, effort: Maybe, params: GenerateContentParams): Future => Future.attemptP(async () => { - const client = new OpenAI({ apiKey }); - return await client.responses.create(buildApiKeyParams(model, effort, params)); - }) - .mapRej((error) => new Error(`Failed to create OpenAI response: ${error instanceof Error ? error.message : String(error)}`)) - .chain((response) => extractResponse({ text: fromOptional(response.output_text) })); - -const callOpenAIWithOAuth = (authToken: string, model: string, effort: Maybe, params: GenerateContentParams): Future => - Future.attemptP(async () => { - const client = new OpenAI({ baseURL: "https://chatgpt.com/backend-api/codex", apiKey: authToken }); - const stream = client.responses.stream(buildOAuthParams(model, effort, params)); + const stream = client.responses.stream(buildStreamParams(model, effort, params)); let deltaSnapshotText = ""; let doneEventText = ""; @@ -84,6 +62,12 @@ const callOpenAIWithOAuth = (authToken: string, model: string, effort: Maybe new Error(`Failed to create OpenAI response: ${error instanceof Error ? error.message : String(error)}`)) .chain((bundle) => extractResponse({ text: extractStreamText(bundle) })); +const callOpenAIWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => + callOpenAIStream(new OpenAI({ apiKey }), model, effort, params); + +const callOpenAIWithOAuth = (authToken: string, model: string, effort: Maybe, params: GenerateContentParams): Future => + callOpenAIStream(new OpenAI({ baseURL: "https://chatgpt.com/backend-api/codex", apiKey: authToken }), model, effort, params); + const generateContentWithOpenAI = (config: OpenAIConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { case "api_key": From 579e9368be795e717cd0bba72598ec484b54564a Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 13:45:19 -0300 Subject: [PATCH 21/38] Switch Anthropic client to streaming API using `finalMessage` --- src/infra/llm/anthropic.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/infra/llm/anthropic.ts b/src/infra/llm/anthropic.ts index dabb906..f38abca 100644 --- a/src/infra/llm/anthropic.ts +++ b/src/infra/llm/anthropic.ts @@ -12,7 +12,7 @@ import { unsupportedAuth } from "@/domain/llm/auth-error"; import { Just, fromOptional, type Maybe } from "@/libs/maybe"; type AnthropicConfig = Extract; -type SystemParam = NonNullable; +type SystemParam = NonNullable; const BASE_MAX_TOKENS = 16384; @@ -27,8 +27,8 @@ const buildParams = ( system: Maybe, effort: Maybe, params: GenerateContentParams -): Anthropic.MessageCreateParamsNonStreaming => { - const core: Anthropic.MessageCreateParamsNonStreaming = { +): Anthropic.MessageStreamParams => { + const core: Anthropic.MessageStreamParams = { model, max_tokens: BASE_MAX_TOKENS, messages: [{ role: "user", content: params.prompt }], @@ -47,10 +47,11 @@ const buildSetupTokenSystem = (instruction: Maybe): SystemParam => const callAnthropicWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => Future.attemptP(async () => { const client = new Anthropic({ apiKey }); - return await client.messages.create(buildParams(model, fromOptional(params.systemInstruction), effort, params)); + const stream = client.messages.stream(buildParams(model, fromOptional(params.systemInstruction), effort, params)); + return await stream.finalMessage(); }) .mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`)) - .chain((response) => extractResponse({ text: Just(extractAnthropicText(response.content)) })); + .chain((message) => extractResponse({ text: Just(extractAnthropicText(message.content)) })); const callAnthropicWithSetupToken = ( authToken: string, @@ -65,10 +66,11 @@ const callAnthropicWithSetupToken = ( defaultHeaders: anthropicOAuthHeaders() }); const system = Just(buildSetupTokenSystem(fromOptional(params.systemInstruction))); - return await client.messages.create(buildParams(model, system, effort, params)); + const stream = client.messages.stream(buildParams(model, system, effort, params)); + return await stream.finalMessage(); }) .mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`)) - .chain((response) => extractResponse({ text: Just(extractAnthropicText(response.content)) })); + .chain((message) => extractResponse({ text: Just(extractAnthropicText(message.content)) })); const generateContentWithAnthropic = (config: AnthropicConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { From 5e7cc38fde5d761f90666a6d72d8b1a000ef5dfe Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 13:46:31 -0300 Subject: [PATCH 22/38] Reformat arrow functions and trailing commas in MVar and Queue - Wrap single-parameter arrow function params in parentheses across `mvar.ts` and `queue.ts`. - Remove trailing commas from function call argument lists. - Collapse some multi-line expressions onto a single line. --- src/libs/mvar.ts | 17 ++++++++--------- src/libs/queue.ts | 8 +++----- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/libs/mvar.ts b/src/libs/mvar.ts index d1cfbfc..df22274 100644 --- a/src/libs/mvar.ts +++ b/src/libs/mvar.ts @@ -23,7 +23,7 @@ class MVar { private value: Value; private constructor(initial: Maybe) { - this.value = initial.maybe>({ tag: "empty" }, value => ({ tag: "full", value })); + this.value = initial.maybe>({ tag: "empty" }, (value) => ({ tag: "full", value })); } static new(v: A): MVar { @@ -48,13 +48,13 @@ class MVar { // If the MVar is full, it blocks until it becomes empty. put(v: A): Promise { const enqueue = () => - new Promise(resolve => { + new Promise((resolve) => { this.waitingEmpty.enqueue([ v, () => { this._unblockWaitingFull(v); resolve(); - }, + } ]); }); @@ -107,8 +107,8 @@ class MVar { // If the MVar is empty, it blocks until it becomes full. take(): Promise { const enqueue = () => - new Promise(resolve => { - this.waitingFull.enqueue(v => { + new Promise((resolve) => { + this.waitingFull.enqueue((v) => { this._unblockWaitingEmpty(); resolve(v); return Promise.resolve(); @@ -163,8 +163,7 @@ class MVar { } }; - const enqueue = (): Promise => - new Promise((resolve, reject) => this.waitingFull.enqueue(value => resume(value).then(resolve).catch(reject))); + const enqueue = (): Promise => new Promise((resolve, reject) => this.waitingFull.enqueue((value) => resume(value).then(resolve).catch(reject))); switch (this.value.tag) { case "empty": @@ -181,7 +180,7 @@ class MVar { /* Like 'modify' but without returning a value. */ async modify_(f: (v: A) => Promise): Promise { - return this.modify(v => f(v).then(x => [x, undefined])); + return this.modify((v) => f(v).then((x) => [x, undefined])); } // Get the value of the MVar without removing it. @@ -190,7 +189,7 @@ class MVar { if (v instanceof Just) { return v.value; } - return await this.modify(async v => [v, v]); + return await this.modify(async (v) => [v, v]); } // Like read, but doesn't block. diff --git a/src/libs/queue.ts b/src/libs/queue.ts index 27f1a14..1cc6a87 100644 --- a/src/libs/queue.ts +++ b/src/libs/queue.ts @@ -8,7 +8,7 @@ class Queue { private constructor( public readonly length: number, private front: List, - private back: List, + private back: List ) {} static new(): Queue { @@ -48,9 +48,7 @@ class Queue { return queue.dequeue(); }; - return this.front - .head() - .unwrap(reverseBack, v => Just([v, new Queue(this.length - 1, this.front.tail(), this.back)])); + return this.front.head().unwrap(reverseBack, (v) => Just([v, new Queue(this.length - 1, this.front.tail(), this.back)])); } } @@ -90,7 +88,7 @@ class MQueue { ([v, queue]) => { this.queue = queue; return Just(v); - }, + } ); } } From ee1024a07df901d18718976dd9d3f93705596931 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 13:53:07 -0300 Subject: [PATCH 23/38] Expand documentation for `MVar`, `Queue`, and `MQueue` - Replace terse inline comments with JSDoc on `MVar`, `Queue`, and `MQueue` methods, covering blocking semantics, return types, and rollback behaviour of `modify`. - Rewrite the `MVar` section of `CONVENTIONS.md` with concrete usage patterns: completion/error latch, end-of-stream latch via `AsyncIterable`, and atomic state mutation through `modify`. - Add a new `Queue` & `MQueue` conventions section explaining the persistent vs. transient distinction and the amortised O(1) two-list representation. - Drop `BoundedBuffer` guidance from the conventions in favour of the updated `MVar` patterns. --- CONVENTIONS.md | 88 ++++++++++++++++++++++++++++++++++++----------- src/libs/mvar.ts | 68 +++++++++++++++++++++++------------- src/libs/queue.ts | 43 +++++++++++++++++++---- 3 files changed, 149 insertions(+), 50 deletions(-) diff --git a/CONVENTIONS.md b/CONVENTIONS.md index f4e2644..97a235d 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -220,28 +220,74 @@ Use `Future` instead of `Promise` for lazy, cancelable async. - `TreeSet`: `.insert()`, `.remove()`, `.union()` mutate in place. Use `TreeSet.from()` to clone first. - Use `.has()` for O(log n) membership. Don't use `.values().includes()` — that's O(n). -### MVar & BoundedBuffer — Async Coordination - -- Use `MVar` for async synchronization. `put(v)` blocks if full, `take()` blocks if empty. Resolves in FIFO order. -- Use `BoundedBuffer` for backpressure queues with max capacity. `enqueue(v)` blocks if full, `dequeue()` blocks if empty. - - ```ts - const textBuffer = new BoundedBuffer(100); - const endSignal = MVar.newEmpty(); - - model.onToken((token) => { - textBuffer.enqueue(token); - }); - model.onDone(() => { - endSignal.put(null); - }); - - for await (const text of iterable) { - await ttsService.synthesize(text); +### MVar — Async Coordination + +Use `MVar` to coordinate concurrent operations through a single mutable cell. Operations are FIFO — fair across waiters, no starvation. + +- Construct with `MVar.new(v)` (full) or `MVar.newEmpty()` (empty). +- `put`/`take`/`read`/`modify` block; `tryPut`/`tryTake`/`tryRead` don't. +- `tryPut` returns `boolean`; `tryTake`/`tryRead` return `Maybe` — pattern match with `instanceof Just` / `instanceof Nothing`. +- Use `modify` (not external locks) to atomically transform shared state — the original value is restored if the callback rejects. +- Don't busy-loop on `tryTake` — use `take` to block. +- Don't use boolean flags or unbounded arrays for "done" state — use `MVar` to block until populated. + +**Completion/error latch with `MVar>`** — surface either "closed cleanly" or "closed with error" from a callback-based lifecycle. + +```ts +const done: MVar> = MVar.newEmpty(); +ws.on("close", () => { + done.tryPut(Nothing()); +}); +ws.on("error", (err) => { + done.tryPut(Just(err)); +}); + +const result = await done.take(); +if (result instanceof Just) throw result.value; +``` + +**End-of-stream latch with `MVar`** — convert a callback-based "done" signal into a value awaitable from an `AsyncIterable`. + +```ts +const end = MVar.newEmpty(); +const finished = end.take(); + +const iterable: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: () => Promise.race([buffer.dequeue().then((value) => ({ done: false, value })), finished.then(() => ({ done: true, value: undefined }))]) + }; } - ``` - -- Don't use unbounded arrays for streaming — memory leak risk. Don't use boolean flags for "done" state — use `MVar` to block until populated. +}; + +model.onDone(() => { + end.put(null); +}); +``` + +**Atomic state via `modify`** — guard mutable shared state with automatic rollback on rejection. + +```ts +const counter = MVar.new(0); +const previous = await counter.modify(async (n) => [n + 1, n]); +``` + +### Queue & MQueue — Functional Queues + +- Use `Queue` for immutable persistent queues — `enqueue` returns a new `Queue`; safe to share across async boundaries without copying. +- Use `MQueue` for transient producer-consumer queues where mutation is local (e.g. waiter queues inside coordination primitives). +- `Queue.dequeue()` returns `Maybe<[T, Queue]>`; `MQueue.dequeue()` returns `Maybe` — both must be pattern-matched. +- Both have amortised O(1) enqueue/dequeue via two-list (front/back) representation. +- Don't reach for native `Array.shift()` for FIFO — it's O(n) and mutates. + +```ts +let q = Queue.fromArray([1, 2, 3]); +const r = q.dequeue(); +if (r instanceof Just) { + const [head, rest] = r.value; + q = rest; // thread the new queue +} +``` --- diff --git a/src/libs/mvar.ts b/src/libs/mvar.ts index df22274..ba35a53 100644 --- a/src/libs/mvar.ts +++ b/src/libs/mvar.ts @@ -8,14 +8,17 @@ type Value = | { tag: "full"; value: T } | { tag: "in_use" }; -/* - A mutable variable that can be full or empty. - Use state-based coordination of concurrent operations. e.g. block until a condition is true/false. - MVars are fair. Puts and takes are resolved in the order in which they were made. - - Trying to put on a full MVar blocks until the MVar is empty. - Trying to take on an empty MVar blocks until the MVar is full. -*/ +/** + * A mutable variable that is either empty or holds one value of type `A`. + * + * Coordinates concurrent operations via FIFO fairness: blocked `put`/`take`/`modify` + * calls resume in the order they were enqueued, so no waiter is starved. + * + * Reach for `MVar` when you need: + * - a completion/error latch (`MVar>`) + * - a single-slot mailbox between producer and consumer + * - atomic mutation of shared state without an external lock (`modify`) + */ class MVar { // Queued writes and modifications are resolved in the order in which they arrive. private waitingEmpty: MQueue<[A, () => void]> = MQueue.new(); @@ -26,10 +29,12 @@ class MVar { this.value = initial.maybe>({ tag: "empty" }, (value) => ({ tag: "full", value })); } + /** Create a full `MVar` holding `v`. The first `take` returns immediately; the first `put` blocks until taken. */ static new(v: A): MVar { return new MVar(Just(v)); } + /** Create an empty `MVar`. The first `put` returns immediately; the first `take` blocks until populated. */ static newEmpty(): MVar { return new MVar(Nothing()); } @@ -44,8 +49,10 @@ class MVar { } } - // Put a value into an empty MVar. - // If the MVar is full, it blocks until it becomes empty. + /** + * Place `v` into the MVar. Resolves once stored. + * If full or in use, blocks (FIFO) until empty. + */ put(v: A): Promise { const enqueue = () => new Promise((resolve) => { @@ -71,8 +78,10 @@ class MVar { } } - // Like put, but doesn't block. - // Returns whether the put was successful or not. + /** + * Non-blocking `put`. Returns `true` if stored, `false` if the MVar + * was full/in-use (and the value was discarded). + */ tryPut(v: A): boolean { switch (this.value.tag) { case "empty": @@ -103,8 +112,10 @@ class MVar { } } - // Take the value in the MVar, leaving it empty. - // If the MVar is empty, it blocks until it becomes full. + /** + * Remove and return the held value, leaving the MVar empty. + * If empty or in use, blocks (FIFO) until a value is put. + */ take(): Promise { const enqueue = () => new Promise((resolve) => { @@ -130,8 +141,10 @@ class MVar { } } - // Like take, but doesn't block. - // Returns whether the take was successful or not. + /** + * Non-blocking `take`. Returns `Just(v)` if a value was taken, + * `Nothing()` if the MVar was empty/in-use. + */ tryTake(): Maybe { switch (this.value.tag) { case "empty": @@ -148,8 +161,13 @@ class MVar { } } - // Modify the value in the MVar. Allows returning a value in the computation too. - // If the MVar is empty, it blocks until it becomes full. + /** + * Atomically transform the held value. `f` receives the current value + * and returns `[newValue, result]`. The MVar is marked in-use during the + * call, blocking other consumers. + * + * If `f` rejects, the original value is restored — safe under failure. + */ async modify(f: (v: A) => Promise<[A, B]>): Promise { const resume = async (value: A): Promise => { try { @@ -177,13 +195,15 @@ class MVar { } } - /* Like 'modify' but without returning a value. - */ + /** Like `modify`, but `f` returns only the new value; no result is computed. */ async modify_(f: (v: A) => Promise): Promise { return this.modify((v) => f(v).then((x) => [x, undefined])); } - // Get the value of the MVar without removing it. + /** + * Read the held value without removing it. + * If empty or in use, blocks (FIFO) until a value is put. + */ async read(): Promise { const v = this.tryRead(); if (v instanceof Just) { @@ -192,8 +212,10 @@ class MVar { return await this.modify(async (v) => [v, v]); } - // Like read, but doesn't block. - // Returns the value if the MVar was full. + /** + * Non-blocking `read`. Returns `Just(v)` if a value is held, + * `Nothing()` if the MVar is empty/in-use. + */ tryRead(): Maybe { switch (this.value.tag) { case "empty": diff --git a/src/libs/queue.ts b/src/libs/queue.ts index 1cc6a87..7775f60 100644 --- a/src/libs/queue.ts +++ b/src/libs/queue.ts @@ -3,7 +3,17 @@ export { Queue, MQueue }; import { List } from "@/libs/list"; import { Maybe, Just, Nothing } from "@/libs/maybe"; -// Immutable queue with amortised O(1) push and pop. +/** + * Immutable queue with amortised O(1) enqueue and dequeue. + * + * Implemented as two singly-linked lists: a `front` list for dequeues + * and a `back` list for enqueues. When the front empties, the back is + * reversed in place to become the new front — at most once per element + * across the queue's lifetime, giving amortised constant time. + * + * Use `Queue` for persistent queues that can be safely shared across + * async boundaries. For in-place mutation, prefer `MQueue`. + */ class Queue { private constructor( public readonly length: number, @@ -11,10 +21,12 @@ class Queue { private back: List ) {} + /** Create an empty queue. */ static new(): Queue { return new Queue(0, List.empty(), List.empty()); } + /** Create a queue containing the elements of `xs`, preserving order. */ static fromArray(xs: T[]): Queue { let queue = Queue.new(); for (const x of xs) { @@ -23,20 +35,25 @@ class Queue { return queue; } + /** True when the queue contains no elements. */ isEmpty(): boolean { return this.length == 0; } + /** Return the queue's elements as an array, front to back. */ toArray(): T[] { return this.front.toArray().concat(this.back.toArray().reverse()); } - // Add to the back of the queue + /** Return a new queue with `v` added to the back. */ enqueue(v: T): Queue { return new Queue(this.length + 1, this.front, List.cons(v, this.back)); } - // Take from the front of the queue + /** + * Remove the front element. Returns `Just([v, rest])` with the front + * value and the remaining queue, or `Nothing()` if empty. + */ dequeue(): Maybe<[T, Queue]> { // Here, if the front of the queue is empty we reverse // the back and make it the front. This is how we achieve @@ -52,36 +69,50 @@ class Queue { } } -// Mutable queue with amortised O(1) enqueue and dequeue. +/** + * Mutable wrapper around `Queue` with amortised O(1) enqueue and dequeue. + * + * Used internally by `MVar` to track waiters. Reach for it directly when a + * local producer/consumer needs in-place mutation without threading a new + * queue value through every call site. + */ class MQueue { private constructor(private queue: Queue) {} + /** Create an empty mutable queue. */ static new(): MQueue { return new MQueue(Queue.new()); } + /** Create a mutable queue containing the elements of `xs`, preserving order. */ static fromArray(xs: T[]): MQueue { return new MQueue(Queue.fromArray(xs)); } + /** True when the queue contains no elements. */ isEmpty(): boolean { return this.queue.isEmpty(); } + /** Return the queue's elements as an array, front to back. */ toArray(): T[] { return this.queue.toArray(); } + /** Number of elements currently in the queue. */ get length(): number { return this.queue.length; } - // Add to the back of the queue + /** Add `v` to the back of the queue. */ enqueue(v: T): void { this.queue = this.queue.enqueue(v); } - // Take from the front of the queue + /** + * Remove and return the front element. Returns `Just(v)` if a value + * was removed, `Nothing()` if the queue was empty. + */ dequeue(): Maybe { return this.queue.dequeue().unwrap( () => Nothing(), From 066012996fdf26ce6ed4950afde6687c7ff3d4bd Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 13:57:03 -0300 Subject: [PATCH 24/38] Refactor commit metadata parsing with NUL-separated fields and decoders - Replace newline-based git log format with `%x00`-separated fields to avoid ambiguity from multi-line subjects. - Introduce `commitMetadataDecoder` along with `nonEmptyString` and `isoDate` decoders for stricter field validation. - Add `splitCommitFields` helper to map raw git output to a structured record before decoding. - Surface decoder error messages in the rejected `Error` for better diagnostics. --- src/infra/git/repo.ts | 51 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/src/infra/git/repo.ts b/src/infra/git/repo.ts index 7e11c09..fa3ddf7 100644 --- a/src/infra/git/repo.ts +++ b/src/infra/git/repo.ts @@ -24,6 +24,7 @@ import { Just, Nothing, type Maybe } from "@/libs/maybe"; import { type Result, Success, Failure } from "@/libs/result"; import { absurd } from "@/libs/types"; import { execBin, type CommandFailure } from "@/infra/shell"; +import * as Decoder from "@/libs/json/decoder"; import { unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -211,14 +212,50 @@ const findTrackingRemoteUrl = (): Future> => .map>((url) => Just(url)) .chainRej(() => Future.resolve>(Nothing())); +const nonEmptyString: Decoder.Decoder = Decoder.string.chain((s) => (s.length > 0 ? Decoder.always(s) : Decoder.fail("expected non-empty string"))); + +const isoDate: Decoder.Decoder = nonEmptyString.chain((s) => { + const d = new Date(s); + return isNaN(d.getTime()) ? Decoder.fail(`invalid ISO date: ${s}`) : Decoder.always(d); +}); + +const commitMetadataDecoder: Decoder.Decoder = Decoder.object({ + hash: nonEmptyString, + short: nonEmptyString, + subject: Decoder.string, + authorName: Decoder.string, + authorEmail: Decoder.string, + date: isoDate +}); + +const COMMIT_FIELDS = [ + ["hash", "%H"], + ["short", "%h"], + ["subject", "%s"], + ["authorName", "%an"], + ["authorEmail", "%ae"], + ["date", "%aI"] +] as const; + +const COMMIT_FORMAT = COMMIT_FIELDS.map(([, p]) => p).join("%x00"); +const COMMIT_KEYS = COMMIT_FIELDS.map(([n]) => n); + +const splitCommitFields = (stdout: string): Result> => { + const parts = stdout.replace(/\n$/, "").split("\x00"); + return parts.length === COMMIT_KEYS.length ? + Success(Object.fromEntries(COMMIT_KEYS.map((k, i) => [k, parts[i]]))) + : Failure(`expected ${COMMIT_KEYS.length} fields, got ${parts.length}`); +}; + const getCommitMetadata = (ref: string = "HEAD"): Future => - execGitChecked(["log", "-1", `--format=%H%n%h%n%s%n%an%n%ae%n%aI`, ref], "Failed to read commit metadata").chain((stdout) => { - const [hash, short, subject, authorName, authorEmail, iso] = stdout.split("\n"); - // TODO: This is specially hard to understand and maintain, consider using a more robust serialization format in the future (e.g. JSON output from git log with a custom format) - return hash && short && subject !== undefined && authorName !== undefined && authorEmail !== undefined && iso ? - Future.resolve({ hash, short, subject, authorName, authorEmail, date: new Date(iso) }) - : Future.reject(new Error("Malformed git log output")); - }); + execGitChecked(["log", "-1", `--format=${COMMIT_FORMAT}`, ref], "Failed to read commit metadata").chain((stdout) => + splitCommitFields(stdout) + .chain((obj) => Decoder.decode(obj, commitMetadataDecoder)) + .either( + (msg) => Future.reject(new Error(`Malformed git log output: ${msg}`)), + (md) => Future.resolve(md) + ) + ); const findCommitMetadata = (ref: string = "HEAD"): Future> => getCommitMetadata(ref) From a1e23099aa5d5ebb05ca6edbd46692a537929252 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 14:01:11 -0300 Subject: [PATCH 25/38] Refactor `classifyFailure` to use named regex helpers and cache stderr text --- src/infra/git/repo.ts | 4 +++- src/infra/github/pr.ts | 14 +++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/infra/git/repo.ts b/src/infra/git/repo.ts index fa3ddf7..6be4a94 100644 --- a/src/infra/git/repo.ts +++ b/src/infra/git/repo.ts @@ -212,7 +212,9 @@ const findTrackingRemoteUrl = (): Future> => .map>((url) => Just(url)) .chainRej(() => Future.resolve>(Nothing())); -const nonEmptyString: Decoder.Decoder = Decoder.string.chain((s) => (s.length > 0 ? Decoder.always(s) : Decoder.fail("expected non-empty string"))); +const nonEmptyString: Decoder.Decoder = Decoder.string.chain((s) => + s.length > 0 ? Decoder.always(s) : Decoder.fail("expected non-empty string") +); const isoDate: Decoder.Decoder = nonEmptyString.chain((s) => { const d = new Date(s); diff --git a/src/infra/github/pr.ts b/src/infra/github/pr.ts index 71fdb7c..6b7da15 100644 --- a/src/infra/github/pr.ts +++ b/src/infra/github/pr.ts @@ -11,9 +11,13 @@ type PullRequest = { url: string; number: number }; type PrLookup = { type: "found"; pr: PullRequest } | { type: "not-found" } | { type: "unauthenticated" } | { type: "unavailable" }; -// TODO: This looks like a "magical number", we need to think more about this +// Matches: git@github.com:owner/repo.git, https://github.com/owner/repo const GITHUB_REPO_RE = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/; + +// Matches gh stderr like: "error connecting... gh auth login to authenticate" const GH_UNAUTH_RE = /not logged into|gh auth login|authentication required/i; + +// Matches gh stderr like: "no pull requests found for branch " const GH_NOT_FOUND_RE = /no pull requests? found/i; const parseGithubRepo = (url: string): Maybe => { @@ -21,6 +25,9 @@ const parseGithubRepo = (url: string): Maybe => { return m && m[1] ? Just(m[1]) : Nothing(); }; +const isGhUnauthenticated = (text: string): boolean => GH_UNAUTH_RE.test(text); +const isGhPrNotFound = (text: string): boolean => GH_NOT_FOUND_RE.test(text); + const prDecoder: Decoder.Decoder = Decoder.object({ url: Decoder.string, number: Decoder.number @@ -35,9 +42,10 @@ const parsePrJson = (stdout: string): PrLookup => const commandFailureText = (failure: CommandFailure): string => failure.output.stderr.trim() || failure.output.stdout.trim() || failure.error.message; const classifyFailure = (failure: CommandFailure): PrLookup => { + const text = commandFailureText(failure); return ( - GH_UNAUTH_RE.test(commandFailureText(failure)) ? { type: "unauthenticated" } - : GH_NOT_FOUND_RE.test(commandFailureText(failure)) ? { type: "not-found" } + isGhUnauthenticated(text) ? { type: "unauthenticated" } + : isGhPrNotFound(text) ? { type: "not-found" } : { type: "unavailable" } ); }; From 6ea0793835c1bc5f15b908e73e4ba91fcfcf5467 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 14:03:59 -0300 Subject: [PATCH 26/38] Change default Anthropic and Gemini effort to medium --- src/infra/ui/effort-picker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/infra/ui/effort-picker.ts b/src/infra/ui/effort-picker.ts index e76df40..d382d48 100644 --- a/src/infra/ui/effort-picker.ts +++ b/src/infra/ui/effort-picker.ts @@ -44,7 +44,7 @@ const selectOpenAIEffort = (modelId: string, current: Maybe): Futu selectEffort(OPENAI_EFFORTS, modelId, current, "medium"); const selectAnthropicEffort = (modelId: string, current: Maybe): Future> => - selectEffort(ANTHROPIC_EFFORTS, modelId, current, "high"); + selectEffort(ANTHROPIC_EFFORTS, modelId, current, "medium"); const selectGeminiEffort = (modelId: string, current: Maybe): Future> => - selectEffort(GEMINI_EFFORTS, modelId, current, ThinkingLevel.HIGH); + selectEffort(GEMINI_EFFORTS, modelId, current, ThinkingLevel.MEDIUM); From 386e74b52cab04d321a80cdb0ede1082a4fd04f3 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 14:10:34 -0300 Subject: [PATCH 27/38] Refactor UI pickers to use `Future.create` instead of wrapping Promises - Replace `new Promise` inside `Future.attemptP` with `Future.create` chained after async imports in both `effort-picker` and `model-picker`. - Remove error-based cancellation workaround in `effort-picker`; resolve with `Nothing` directly on cancel instead of rejecting and catching in `chainRej`. - Return `unmount` as the teardown function from `Future.create` for proper cleanup. --- src/infra/ui/effort-picker.ts | 13 +++++++------ src/infra/ui/model-picker.ts | 11 +++++++---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/infra/ui/effort-picker.ts b/src/infra/ui/effort-picker.ts index d382d48..801a460 100644 --- a/src/infra/ui/effort-picker.ts +++ b/src/infra/ui/effort-picker.ts @@ -15,12 +15,12 @@ const selectEffort = (options: readonly V[], modelId: string, }); return Future.attemptP(async () => { - // TODO: Why this is imported here and not at the top of the file? const { render } = await import("ink"); const React = await import("react"); const sliderModule: EffortSliderModule = await import("@/infra/ui/effort-slider"); - - return new Promise>((resolve, reject) => { + return { render, React, sliderModule }; + }).chain(({ render, React, sliderModule }) => + Future.create>((_reject, resolve) => { const { unmount } = render( React.createElement(sliderModule.EffortSlider, { title: `Reasoning effort for ${modelId}`, @@ -32,12 +32,13 @@ const selectEffort = (options: readonly V[], modelId: string, }, onCancel: () => { unmount(); - reject(new Error("Selection cancelled")); + resolve(Nothing()); } }) ); - }); - }).chainRej((err) => (err.message === "Selection cancelled" ? Future.resolve(Nothing()) : Future.reject(err))); + return () => unmount(); + }) + ); }; const selectOpenAIEffort = (modelId: string, current: Maybe): Future> => diff --git a/src/infra/ui/model-picker.ts b/src/infra/ui/model-picker.ts index b32e57e..2702577 100644 --- a/src/infra/ui/model-picker.ts +++ b/src/infra/ui/model-picker.ts @@ -5,11 +5,13 @@ import { Model } from "@/domain/config/config"; const selectModelInteractively = (models: Model[]): Future => Future.attemptP(async () => { + // Lazy-load Ink/React so non-interactive CLI paths don't pay their startup cost. const { render } = await import("ink"); const React = await import("react"); const { ModelSelector } = await import("@/infra/ui/model-selector"); - - return new Promise((resolve, reject) => { + return { render, React, ModelSelector }; + }).chain(({ render, React, ModelSelector }) => + Future.create((reject, resolve) => { const { unmount } = render( React.createElement(ModelSelector, { models, @@ -23,5 +25,6 @@ const selectModelInteractively = (models: Model[]): Future => } }) ); - }); - }); + return () => unmount(); + }) + ); From b27d5c868fd2fb723725a47b72af7aaf2cd9634c Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 14:14:41 -0300 Subject: [PATCH 28/38] Move `resolveAuthMethod` to config module and refactor token updates - Relocate `resolveAuthMethod` from `auth-resolver.ts` to `config.ts` so it can be reused across modules. - Refactor `updateGoogleTokens` and `updateOpenAITokens` to use exhaustive `switch` statements with `absurd` for type-safe handling of all auth methods. - Replace ad-hoc object spreads with `resolveAuthMethod` when persisting refreshed tokens. --- src/domain/config/config.ts | 15 ++++++++++ src/domain/llm/auth-resolver.ts | 15 +--------- src/infra/storage/config.ts | 53 +++++++++++++++++---------------- 3 files changed, 44 insertions(+), 39 deletions(-) diff --git a/src/domain/config/config.ts b/src/domain/config/config.ts index d71e737..986001c 100644 --- a/src/domain/config/config.ts +++ b/src/domain/config/config.ts @@ -14,6 +14,7 @@ export { schema_OpenAITokens, schema_AuthMethod, schema_ProviderConfig, + resolveAuthMethod, AI_PROVIDERS, COMMIT_CONVENTIONS, OPENAI_EFFORTS, @@ -23,6 +24,7 @@ export { import * as s from "@/libs/json/schema"; +import { absurd } from "@/libs/types"; import { ThinkingLevel } from "@google/genai"; import type OpenAIPkg from "openai"; @@ -101,6 +103,19 @@ const schema_ProviderConfig = s.discriminatedUnion([ ]); type ProviderConfig = s.Infer; +const resolveAuthMethod = (ai: ProviderConfig, auth_method: ProviderConfig["auth_method"]): ProviderConfig => { + switch (ai.provider) { + case "openai": + return { provider: "openai", model: ai.model, auth_method, effort: ai.effort }; + case "anthropic": + return { provider: "anthropic", model: ai.model, auth_method, effort: ai.effort }; + case "gemini": + return { provider: "gemini", model: ai.model, auth_method, effort: ai.effort }; + default: + return absurd(ai, "ProviderConfig"); + } +}; + const Config = s.object({ ai: schema_ProviderConfig, commit_convention: s.stringEnum([...COMMIT_CONVENTIONS]), diff --git a/src/domain/llm/auth-resolver.ts b/src/domain/llm/auth-resolver.ts index b37ec98..ccce46c 100644 --- a/src/domain/llm/auth-resolver.ts +++ b/src/domain/llm/auth-resolver.ts @@ -2,7 +2,7 @@ export { resolveProvider }; import { Future } from "@/libs/future"; import { Just, Nothing, type Maybe } from "@/libs/maybe"; -import { type Config, type ProviderConfig, type RefreshTokens } from "@/domain/config/config"; +import { resolveAuthMethod, type Config, type ProviderConfig, type RefreshTokens } from "@/domain/config/config"; import { ensureFreshTokens } from "@/infra/auth/google"; import { ensureFreshOpenAITokens } from "@/infra/auth/openai"; import { updateGoogleTokens, updateOpenAITokens } from "@/infra/storage/config"; @@ -26,19 +26,6 @@ const refreshAndPersist: RefreshAndPersistFlow = (tokens, refresh, persist) => ) ); -const resolveAuthMethod = (ai: ProviderConfig, auth_method: ProviderConfig["auth_method"]): ProviderConfig => { - switch (ai.provider) { - case "openai": - return { provider: "openai", model: ai.model, auth_method, effort: ai.effort }; - case "anthropic": - return { provider: "anthropic", model: ai.model, auth_method, effort: ai.effort }; - case "gemini": - return { provider: "gemini", model: ai.model, auth_method, effort: ai.effort }; - default: - return absurd(ai, "ProviderConfig"); - } -}; - const resolveProvider: ResolveProvider = (config) => { const { ai } = config; diff --git a/src/infra/storage/config.ts b/src/infra/storage/config.ts index a09ab98..de3bee3 100644 --- a/src/infra/storage/config.ts +++ b/src/infra/storage/config.ts @@ -6,7 +6,8 @@ import { Future } from "@/libs/future"; import { resolve } from "node:path"; import { homedir } from "node:os"; import { readFile, writeFile, mkdir } from "node:fs/promises"; -import { Config, type OAuthTokens, type OpenAITokens } from "@/domain/config/config"; +import { Config, resolveAuthMethod, type OAuthTokens, type OpenAITokens } from "@/domain/config/config"; +import { absurd } from "@/libs/types"; const CONFIG_DIR = resolve(homedir(), ".commit-tools"); const CONFIG_FILE = resolve(CONFIG_DIR, "config.json"); @@ -31,34 +32,36 @@ const saveConfig = (config: Config): Future => const updateGoogleTokens = (tokens: OAuthTokens): Future => loadConfig().chain((config) => { - // TODO: Why we are doing this? - if (config.ai.provider !== "gemini" || config.ai.auth_method.type !== "google_oauth") { - return Future.reject(new Error("Cannot update tokens: not using Google OAuth authentication")); + switch (config.ai.auth_method.type) { + case "google_oauth": + return saveConfig({ + ai: resolveAuthMethod(config.ai, { type: "google_oauth", content: tokens }), + commit_convention: config.commit_convention, + custom_template: config.custom_template + }); + case "api_key": + case "openai_oauth": + case "anthropic_setup_token": + return Future.reject(new Error("Cannot update tokens: not using Google OAuth authentication")); + default: + return absurd(config.ai.auth_method, "AuthMethod"); } - return saveConfig({ - ...config, - ai: { - provider: "gemini", - model: config.ai.model, - auth_method: { type: "google_oauth", content: tokens }, - effort: config.ai.effort - } - }); }); const updateOpenAITokens = (tokens: OpenAITokens): Future => loadConfig().chain((config) => { - // TODO: Why we are doing this? - if (config.ai.provider !== "openai" || config.ai.auth_method.type !== "openai_oauth") { - return Future.reject(new Error("Cannot update tokens: not using OpenAI OAuth authentication")); + switch (config.ai.auth_method.type) { + case "openai_oauth": + return saveConfig({ + ai: resolveAuthMethod(config.ai, { type: "openai_oauth", content: tokens }), + commit_convention: config.commit_convention, + custom_template: config.custom_template + }); + case "api_key": + case "google_oauth": + case "anthropic_setup_token": + return Future.reject(new Error("Cannot update tokens: not using OpenAI OAuth authentication")); + default: + return absurd(config.ai.auth_method, "AuthMethod"); } - return saveConfig({ - ...config, - ai: { - provider: "openai", - model: config.ai.model, - auth_method: { type: "openai_oauth", content: tokens }, - effort: config.ai.effort - } - }); }); From 742054f5a85b748e6710f3ba199417a7b446b22b Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 14:17:15 -0300 Subject: [PATCH 29/38] Expand comments explaining lazy Ink/React imports in pickers - Document why `selectEffort` uses dynamic `import()` to avoid Ink/React startup cost on non-interactive CLI paths. - Add the same rationale to `selectModelInteractively`, calling out scripted runs, `--yes` flows, piped stdin, and git hook integrations. --- src/infra/ui/effort-picker.ts | 7 +++++++ src/infra/ui/model-picker.ts | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/src/infra/ui/effort-picker.ts b/src/infra/ui/effort-picker.ts index 801a460..1cc480e 100644 --- a/src/infra/ui/effort-picker.ts +++ b/src/infra/ui/effort-picker.ts @@ -15,6 +15,13 @@ const selectEffort = (options: readonly V[], modelId: string, }); return Future.attemptP(async () => { + // Lazy-load Ink/React so non-interactive CLI paths don't pay their startup cost. + // Ink pulls in React, Yoga layout, and a render loop — non-trivial to initialize + // even for commands that never reach an interactive prompt (scripted runs, + // --yes flows, piped stdin, git hook integrations). A static top-level import + // would charge every CLI entrypoint for that cost regardless of whether the + // picker is ever shown; dynamic import defers it until the user actually + // reaches this code path. const { render } = await import("ink"); const React = await import("react"); const sliderModule: EffortSliderModule = await import("@/infra/ui/effort-slider"); diff --git a/src/infra/ui/model-picker.ts b/src/infra/ui/model-picker.ts index 2702577..60440d0 100644 --- a/src/infra/ui/model-picker.ts +++ b/src/infra/ui/model-picker.ts @@ -6,6 +6,12 @@ import { Model } from "@/domain/config/config"; const selectModelInteractively = (models: Model[]): Future => Future.attemptP(async () => { // Lazy-load Ink/React so non-interactive CLI paths don't pay their startup cost. + // Ink pulls in React, Yoga layout, and a render loop — non-trivial to initialize + // even for commands that never reach an interactive prompt (scripted runs, + // --yes flows, piped stdin, git hook integrations). A static top-level import + // would charge every CLI entrypoint for that cost regardless of whether the + // picker is ever shown; dynamic import defers it until the user actually + // reaches this code path. const { render } = await import("ink"); const React = await import("react"); const { ModelSelector } = await import("@/infra/ui/model-selector"); From a5eddc33ff3fcfd663825fe3090c94be50ceb73c Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 14:25:17 -0300 Subject: [PATCH 30/38] Lowercase effort slider labels --- src/infra/ui/effort-slider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/infra/ui/effort-slider.tsx b/src/infra/ui/effort-slider.tsx index 3d2b55d..88d3271 100644 --- a/src/infra/ui/effort-slider.tsx +++ b/src/infra/ui/effort-slider.tsx @@ -103,7 +103,7 @@ const EffortSlider = ({ title, options, initialIndex, onSubmit - {labels} + {labels.toLocaleLowerCase()} From 1155dbecd9bbde88c70d7fc1e862ebefb4d5ecde Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 14:29:42 -0300 Subject: [PATCH 31/38] Show Google OAuth completion notice - Add an optional notice area to the success auth template. - Add a Google OAuth notice explaining that terminal completion can take 1-2 minutes. - Use `GOOGLE_SUCCESS_HTML` for the Google OAuth callback response. --- src/infra/auth/google.ts | 4 ++-- src/infra/auth/templates.ts | 30 ++++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/infra/auth/google.ts b/src/infra/auth/google.ts index a192309..03beb4a 100644 --- a/src/infra/auth/google.ts +++ b/src/infra/auth/google.ts @@ -1,7 +1,7 @@ export { performOAuthFlow, createAuthenticatedClient, ensureFreshTokens, validateOAuthTokens, getAccessToken }; import { type OAuthTokens } from "@/domain/config/config"; -import { SUCCESS_HTML, ERROR_HTML } from "@/infra/auth/templates"; +import { GOOGLE_SUCCESS_HTML, ERROR_HTML } from "@/infra/auth/templates"; import { OAuth2Client, CodeChallengeMethod } from "google-auth-library"; import { Future } from "@/libs/future"; import { environment } from "@/infra/env"; @@ -99,7 +99,7 @@ const startCallbackServer = (port: number, state: string): Future { diff --git a/src/infra/auth/templates.ts b/src/infra/auth/templates.ts index 2176585..a339385 100644 --- a/src/infra/auth/templates.ts +++ b/src/infra/auth/templates.ts @@ -1,4 +1,4 @@ -export { COMMON_STYLE, SUCCESS_HTML, ERROR_HTML }; +export { COMMON_STYLE, SUCCESS_HTML, GOOGLE_SUCCESS_HTML, ERROR_HTML }; const COMMON_STYLE = ` `; -const SUCCESS_HTML = ` +const successHtml = (notice?: string): string => ` @@ -79,10 +94,21 @@ const SUCCESS_HTML = `

Authentication Successful

You have successfully connected your account. You can now close this tab and return to your terminal.

+ ${notice ?? ""} `; +const SUCCESS_HTML = successHtml(); + +const GOOGLE_OAUTH_NOTICE = ` +
+ Google OAuth: It can take 1-2 minutes after this page appears for the terminal to + continue. Keep the terminal open while it finishes. +
`; + +const GOOGLE_SUCCESS_HTML = successHtml(GOOGLE_OAUTH_NOTICE); + const ERROR_HTML = (message: string): string => ` From f0ab8a3df069e7b31e72721637ac576d68114907 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 14:29:56 -0300 Subject: [PATCH 32/38] Add shared fuzzy search for model selection - Add reusable `match` and `search` helpers for scored fuzzy matching. - Update the model selector to rank results across model IDs and descriptions. --- src/infra/ui/model-selector.tsx | 22 +------ src/libs/fuzzy.ts | 111 ++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 src/libs/fuzzy.ts diff --git a/src/infra/ui/model-selector.tsx b/src/infra/ui/model-selector.tsx index 1b64a64..f8f44f0 100644 --- a/src/infra/ui/model-selector.tsx +++ b/src/infra/ui/model-selector.tsx @@ -6,6 +6,8 @@ import { Box, Text, useInput, useApp, type Key } from "ink"; import chalk from "chalk"; +import { search } from "@/libs/fuzzy"; + type Model = { id: string; description: string; @@ -17,20 +19,6 @@ type ModelSelectorProps = { onCancel: () => void; }; -function fuzzyMatch(pattern: string, target: string): boolean { - const p = pattern.toLowerCase(); - const t = target.toLowerCase(); - let pi = 0; - let ti = 0; - - while (pi < p.length && ti < t.length) { - if (p[pi] === t[ti]) pi++; - ti++; - } - - return pi === p.length; -} - function deleteWordLeft(value: string, cursor: number): [string, number] { let i = cursor - 1; while (i > 0 && value[i - 1] === " ") i--; @@ -58,11 +46,7 @@ const ModelSelector = ({ models, onSelect, onCancel }: ModelSelectorProps) => { const [cursorOffset, setCursorOffset] = React.useState(0); const [selectedIndex, setSelectedIndex] = React.useState(0); - const filteredModels = models.filter((m) => { - const q = query.trim(); - if (!q) return true; - return fuzzyMatch(q, m.id) || (m.description && fuzzyMatch(q, m.description)); - }); + const filteredModels = React.useMemo(() => search(query, models, [(m) => m.id, (m) => m.description]).map((r) => r.item), [query, models]); const handleLifecycle = (key: Key): boolean => { if (key.escape) { diff --git a/src/libs/fuzzy.ts b/src/libs/fuzzy.ts new file mode 100644 index 0000000..d3cc2e9 --- /dev/null +++ b/src/libs/fuzzy.ts @@ -0,0 +1,111 @@ +export { match, search, type Match, type SearchOptions }; + +import { type Maybe, Just, Nothing, mapMaybe } from "@/libs/maybe"; + +type Match = { + score: number; + positions: ReadonlyArray; +}; + +type SearchOptions = { + caseSensitive?: boolean; + separators?: RegExp; +}; + +const SEPARATORS = /[\s\-_./]+/; +const SEPARATOR_CHAR = /[\s\-_./]/; + +const SCORE_MATCH = 16; +const BONUS_BOUNDARY = 8; +const BONUS_CONSECUTIVE = 4; +const PENALTY_GAP_START = -3; +const PENALTY_GAP_EXTEND = -1; + +const applyCase = (s: string, caseSensitive: boolean): string => (caseSensitive ? s : s.toLowerCase()); + +const isBoundary = (target: string, i: number): boolean => { + if (i === 0) return true; + const prev = target[i - 1]; + return prev !== undefined && SEPARATOR_CHAR.test(prev); +}; + +type TokenScore = { score: number; end: number; positions: ReadonlyArray }; +type GapScan = { matchIndex: number; gapPenalty: number }; + +const scanForChar = (target: string, from: number, ch: string): Maybe => { + let ti = from; + let gapPenalty = 0; + let inGap = false; + while (ti < target.length && target[ti] !== ch) { + gapPenalty += inGap ? PENALTY_GAP_EXTEND : PENALTY_GAP_START; + inGap = true; + ti++; + } + return ti >= target.length ? Nothing() : Just({ matchIndex: ti, gapPenalty }); +}; + +const scoreMatchAt = (target: string, ti: number, prevMatchIndex: number): number => { + let score = SCORE_MATCH; + if (isBoundary(target, ti)) score += BONUS_BOUNDARY; + if (prevMatchIndex === ti - 1) score += BONUS_CONSECUTIVE; + return score; +}; + +const scoreToken = (token: string, target: string, from: number): Maybe => { + const positions: number[] = []; + let score = 0; + let prevMatchIndex = -1; + let ti = from; + + for (const ch of token) { + const found = scanForChar(target, ti, ch); + if (found.isNothing()) return Nothing(); + const inner = found.expect("checked above"); + score += inner.gapPenalty + scoreMatchAt(target, inner.matchIndex, prevMatchIndex); + positions.push(inner.matchIndex); + prevMatchIndex = inner.matchIndex; + ti = inner.matchIndex + 1; + } + return Just({ score, end: ti, positions }); +}; + +const scoreAllTokens = (tokens: ReadonlyArray, target: string): Maybe => { + let cursor = 0; + let total = 0; + const allPositions: number[] = []; + for (const tok of tokens) { + const r = scoreToken(tok, target, cursor); + if (r.isNothing()) return Nothing(); + const inner = r.expect("checked above"); + total += inner.score; + allPositions.push(...inner.positions); + cursor = inner.end; + } + return Just({ score: total, positions: allPositions }); +}; + +const match = (query: string, target: string, options?: SearchOptions): Maybe => { + const caseSensitive = options?.caseSensitive ?? /[A-Z]/.test(query); + const sep = options?.separators ?? SEPARATORS; + const tokens = applyCase(query, caseSensitive).split(sep).filter(Boolean); + if (tokens.length === 0) return Just({ score: 0, positions: [] }); + return scoreAllTokens(tokens, applyCase(target, caseSensitive)); +}; + +const search = ( + query: string, + items: ReadonlyArray, + selectors: ReadonlyArray<(item: T) => string>, + options?: SearchOptions +): ReadonlyArray<{ item: T; match: Match }> => { + if (query.trim().length === 0) { + return items.map((item) => ({ item, match: { score: 0, positions: [] } })); + } + const scored = mapMaybe([...items], (item) => { + const matches = mapMaybe([...selectors], (sel) => match(query, sel(item), options)); + if (matches.length === 0) return Nothing<{ item: T; match: Match }>(); + const best = matches.reduce((a, b) => (b.score > a.score ? b : a)); + return Just({ item, match: best }); + }); + return [...scored].sort((a, b) => b.match.score - a.match.score); +}; From 2aaac34b5ed6c0a74be707c1239bc304321cb111 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 14:31:58 -0300 Subject: [PATCH 33/38] Update package version to 0.2.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 97c548e..e23573e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rafaeelricco/commit-tools", - "version": "0.2.4", + "version": "0.2.5", "type": "module", "bin": { "commit": "./dist/index.js" From 945d23e39c4b35f7fb8bdecd1ae1318aeee02103 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 14:35:10 -0300 Subject: [PATCH 34/38] Inline Anthropic max token limit --- src/infra/llm/anthropic.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/infra/llm/anthropic.ts b/src/infra/llm/anthropic.ts index f38abca..82206dc 100644 --- a/src/infra/llm/anthropic.ts +++ b/src/infra/llm/anthropic.ts @@ -14,8 +14,6 @@ import { Just, fromOptional, type Maybe } from "@/libs/maybe"; type AnthropicConfig = Extract; type SystemParam = NonNullable; -const BASE_MAX_TOKENS = 16384; - const extractAnthropicText = (content: Anthropic.ContentBlock[]): string => content .filter((b): b is Anthropic.TextBlock => b.type === "text") @@ -30,7 +28,7 @@ const buildParams = ( ): Anthropic.MessageStreamParams => { const core: Anthropic.MessageStreamParams = { model, - max_tokens: BASE_MAX_TOKENS, + max_tokens: 16384, messages: [{ role: "user", content: params.prompt }], thinking: { type: "adaptive" }, output_config: { effort: effort.withDefault("medium") } @@ -60,11 +58,7 @@ const callAnthropicWithSetupToken = ( params: GenerateContentParams ): Future => Future.attemptP(async () => { - const client = new Anthropic({ - apiKey: null, - authToken, - defaultHeaders: anthropicOAuthHeaders() - }); + const client = new Anthropic({ apiKey: null, authToken, defaultHeaders: anthropicOAuthHeaders() }); const system = Just(buildSetupTokenSystem(fromOptional(params.systemInstruction))); const stream = client.messages.stream(buildParams(model, system, effort, params)); return await stream.finalMessage(); From 7eec983ec202891383229f14cb2f6bee3316423b Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 14:40:38 -0300 Subject: [PATCH 35/38] Add commit effort command for updating reasoning effort - Register the `effort` CLI command and route it to a new interactive effort update flow. - Persist the selected reasoning effort for the current provider in the stored AI config. - Update help output, version reporting, and README docs for `commit effort`. --- README.md | 29 ++++++++++++++++++----------- index.ts | 3 +++ src/cli/effort.ts | 32 ++++++++++++++++++++++++++++++++ src/cli/parser.ts | 14 ++++++++++++-- 4 files changed, 65 insertions(+), 13 deletions(-) create mode 100644 src/cli/effort.ts diff --git a/README.md b/README.md index 8ec7c36..42e24d8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # commit-tools -[![Version](https://img.shields.io/badge/version-0.2.0-blue.svg)](#) +[![Version](https://img.shields.io/badge/version-0.2.5-blue.svg)](#) Writing good commit messages _can_ have a high cognitive cost, especially when you make dozens of commits a day. That energy should be directed toward solving hard problems and shipping features, not summarizing them. @@ -113,6 +113,12 @@ After setup, you can switch AI models from your configured provider at any time: commit model ``` +This flow also lets you adjust the reasoning effort for the chosen model. If the model is already the one you want and you only need to change the effort level, run: + +```bash +commit effort +``` + ### 3. Generate a Commit Stage your changes, then run: @@ -144,16 +150,17 @@ To see all available commands at any time, run: commit --help ``` -| Command | Description | -| ------------------------ | ---------------------------------------- | -| `commit` | Generate a commit message (default) | -| `commit generate` | Generate a commit message | -| `commit setup` | Configure authentication and conventions | -| `commit login` | Alias for setup — re-authenticate | -| `commit doctor` | Check installation and environment | -| `commit model` | Select a different AI model | -| `commit --version`, `-v` | Show version | -| `commit --help`, `-h` | Show help | +| Command | Description | +| ------------------------ | ------------------------------------------------- | +| `commit` | Generate a commit message (default) | +| `commit generate` | Generate a commit message | +| `commit setup` | Configure authentication and conventions | +| `commit login` | Alias for setup — re-authenticate | +| `commit doctor` | Check installation and environment | +| `commit model` | Select a different AI model | +| `commit effort` | Adjust the reasoning effort for the current model | +| `commit --version`, `-v` | Show version | +| `commit --help`, `-h` | Show help | ## Providers diff --git a/index.ts b/index.ts index 059c2b7..9e38aed 100755 --- a/index.ts +++ b/index.ts @@ -2,6 +2,7 @@ import { Commit } from "@/cli/commit"; import { Setup } from "@/cli/setup"; import { Doctor } from "@/cli/doctor"; import { ModelCommand } from "@/cli/model"; +import { EffortCommand } from "@/cli/effort"; import { parseArgs, showHelp, showVersion } from "@/cli/parser"; import { Future } from "@/libs/future"; @@ -26,6 +27,8 @@ const main = () => { return Doctor.create().run(); case "model": return ModelCommand.create().chain((m) => m.run()); + case "effort": + return EffortCommand.create().chain((e) => e.run()); case "version": showVersion(); return Future.resolve(undefined); diff --git a/src/cli/effort.ts b/src/cli/effort.ts new file mode 100644 index 0000000..cd4a2c4 --- /dev/null +++ b/src/cli/effort.ts @@ -0,0 +1,32 @@ +export { EffortCommand }; + +import * as p from "@clack/prompts"; + +import { Future } from "@/libs/future"; +import { type Config } from "@/domain/config/config"; +import { loadConfig, saveConfig } from "@/infra/storage/config"; +import { selectEffortForProvider } from "@/domain/llm/effort"; + +import color from "picocolors"; + +class EffortCommand { + private constructor(private readonly config: Config) {} + + static create(): Future { + return loadConfig() + .chainRej(() => Future.reject(new Error("No configuration found. Run 'commit-tools setup' first."))) + .map((config) => new EffortCommand(config)); + } + + run(): Future { + p.intro(color.bgCyan(color.black(" Change Effort "))); + + return selectEffortForProvider(this.config.ai) + .chain((ai) => saveConfig({ ...this.config, ai })) + .map(() => p.outro(color.green("Effort updated successfully!"))) + .mapRej((e) => { + p.log.error(color.red(e.message)); + return e; + }); + } +} diff --git a/src/cli/parser.ts b/src/cli/parser.ts index 8ff044d..16be71f 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -4,7 +4,14 @@ import * as D from "@/libs/json/decoder"; import { Result } from "@/libs/result"; -type CliCommand = { type: "generate" } | { type: "setup" } | { type: "doctor" } | { type: "model" } | { type: "version" } | { type: "help" }; +type CliCommand = + | { type: "generate" } + | { type: "setup" } + | { type: "doctor" } + | { type: "model" } + | { type: "effort" } + | { type: "version" } + | { type: "help" }; const cliCommandDecoder: D.Decoder = D.array(D.string).chain((args) => { const cmd = args[0] || "generate"; @@ -19,6 +26,8 @@ const cliCommandDecoder: D.Decoder = D.array(D.string).chain((args) return D.succeed({ type: "doctor" as const }); case "model": return D.succeed({ type: "model" as const }); + case "effort": + return D.succeed({ type: "effort" as const }); case "--version": case "-v": return D.succeed({ type: "version" as const }); @@ -42,6 +51,7 @@ Commands: login Alias for setup (re-authenticate) doctor Check installation and environment model Select a different AI model + effort Adjust the reasoning effort for the current model --version, -v Show version --help, -h Show help `); @@ -49,7 +59,7 @@ Commands: const showVersion = (): void => { const start = performance.now(); - console.log("commit-tools 0.2.0 (node)"); + console.log("commit-tools 0.2.5 (node)"); const elapsed = performance.now() - start; console.log(`Done in ${elapsed.toLocaleString()}ms`); }; From bd4514cd8bdcce2dcf60d2bea45499116b01fae0 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 14:43:45 -0300 Subject: [PATCH 36/38] Use package version for CLI version output - Read `package.json` version in `showVersion` instead of hardcoding the CLI version. - Extend the `@/*` path alias to resolve root-level imports such as `package.json`. --- src/cli/parser.ts | 3 ++- tsconfig.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cli/parser.ts b/src/cli/parser.ts index 16be71f..67f1fe8 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -3,6 +3,7 @@ export { type CliCommand, parseArgs, showHelp, showVersion }; import * as D from "@/libs/json/decoder"; import { Result } from "@/libs/result"; +import { version as packageVersion } from "@/package.json"; type CliCommand = | { type: "generate" } @@ -59,7 +60,7 @@ Commands: const showVersion = (): void => { const start = performance.now(); - console.log("commit-tools 0.2.5 (node)"); + console.log(`commit-tools ${packageVersion} (node)`); const elapsed = performance.now() - start; console.log(`Done in ${elapsed.toLocaleString()}ms`); }; diff --git a/tsconfig.json b/tsconfig.json index 58bb089..6573bd2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,7 +25,7 @@ "allowUnusedLabels": false, "allowUnreachableCode": false, "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*", "./*"] } }, "include": ["index.ts", "src/**/*"], From 9c660dc3d2f88c72737e36d7f4ccf561b22065ed Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 16:59:18 -0300 Subject: [PATCH 37/38] Let pending writer trigger complete MVar handoff --- src/libs/mvar.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/mvar.ts b/src/libs/mvar.ts index ba35a53..c6af01b 100644 --- a/src/libs/mvar.ts +++ b/src/libs/mvar.ts @@ -105,10 +105,10 @@ class MVar
{ // nope, let's just mark it as empty this.value = { tag: "empty" }; } else { - // yes, there is a new value here. - const [newVal, trigger] = r.value; + // hand off to the next pending writer; its trigger closure + // takes care of placing the value and resolving the put. + const [, trigger] = r.value; trigger(); - this._unblockWaitingFull(newVal); } } From 86d467fff63d88f71b9787201215c5168fe19986 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 25 Apr 2026 17:00:13 -0300 Subject: [PATCH 38/38] Preserve current effort when cancelling effort picker --- src/infra/ui/effort-picker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/infra/ui/effort-picker.ts b/src/infra/ui/effort-picker.ts index 1cc480e..37a8287 100644 --- a/src/infra/ui/effort-picker.ts +++ b/src/infra/ui/effort-picker.ts @@ -3,7 +3,7 @@ export { selectOpenAIEffort, selectAnthropicEffort, selectGeminiEffort }; import { ThinkingLevel } from "@google/genai"; import { Future } from "@/libs/future"; -import { Just, Nothing, type Maybe } from "@/libs/maybe"; +import { Just, type Maybe } from "@/libs/maybe"; import { OPENAI_EFFORTS, ANTHROPIC_EFFORTS, GEMINI_EFFORTS, type OpenAIEffort, type AnthropicEffort, type GeminiEffort } from "@/domain/config/config"; type EffortSliderModule = typeof import("@/infra/ui/effort-slider"); @@ -39,7 +39,7 @@ const selectEffort = (options: readonly V[], modelId: string, }, onCancel: () => { unmount(); - resolve(Nothing()); + resolve(currentEffort); } }) );