diff --git a/.github/workflows/pr-validate.yml b/.github/workflows/pr-validate.yml index 942eef8..1eea11f 100644 --- a/.github/workflows/pr-validate.yml +++ b/.github/workflows/pr-validate.yml @@ -19,11 +19,11 @@ jobs: with: node-version: "24" - - name: Install pnpm - run: npm install -g pnpm + - name: Enable Corepack + run: corepack enable - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Typecheck run: pnpm run typecheck @@ -51,11 +51,11 @@ jobs: with: node-version: "24" - - name: Install pnpm - run: npm install -g pnpm + - name: Enable Corepack + run: corepack enable - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Lint (cognitive complexity) run: pnpm run lint:ci diff --git a/package.json b/package.json index 61b3698..f1ce9b0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "@rafaeelricco/commit-tools", - "version": "0.2.7", + "version": "0.2.8", "type": "module", + "packageManager": "pnpm@10.33.0", "bin": { "commit": "./dist/index.js" }, diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..b52cd10 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - . + +allowBuilds: + "esbuild@0.27.4": true + "protobufjs@7.5.5": true diff --git a/src/cli/commit.ts b/src/cli/commit.ts index b9c0812..1c37e9d 100644 --- a/src/cli/commit.ts +++ b/src/cli/commit.ts @@ -9,10 +9,10 @@ import { loadConfig } from "@/infra/storage/config"; import { Setup } from "@/cli/setup"; import { type CommitConvention, type Config, type ProviderConfig } from "@/domain/config/config"; import { resolveProvider } from "@/domain/llm/auth-resolver"; -import { generateCommitMessage, refineCommitMessage } from "@/domain/llm/router"; +import { generateCommitMessage, refineCommitMessage, type GeneratedContent, type LlmRequestMetadata } from "@/domain/llm/router"; import { Nothing, type Maybe, Just } from "@/libs/maybe"; import { loading } from "@/infra/ui/spinner"; -import { renderPushNote } from "@/infra/ui/push-note"; +import { renderCommitNote, renderPushNote } from "@/infra/ui/push-note"; import color from "picocolors"; @@ -56,11 +56,11 @@ class Commit { return repo.getStagedDiff(); } - generate(diff: string, convention: CommitConvention, template: Maybe = Nothing()): Future { + generate(diff: string, convention: CommitConvention, template: Maybe = Nothing()): Future { return loading("Generating commit message...", "Message generated!", generateCommitMessage(this.providerConfig, diff, convention, template)); } - refine(message: string, adjustment: string, diff: string): Future { + refine(message: string, adjustment: string, diff: string): Future { return loading("Refining...", "Refined!", refineCommitMessage(this.providerConfig, message, adjustment, diff)); } @@ -68,7 +68,7 @@ class Commit { return repo.performCommit(message); } - push(branch?: string, publish = false, forceWithLease = false): Future { + push(request: Maybe, branch?: string, publish = false, forceWithLease = false): Future { const startMsg = forceWithLease ? "Force pushing with lease..." : publish ? `Publishing '${branch}'...` @@ -95,21 +95,21 @@ class Commit { baseBranch: repo.findBaseBranch(), remoteUrl: repo.findTrackingRemoteUrl(), pr: pr.getOpenPullRequest() - }).map((parts) => renderPushNote({ ...parts, range: result.range })) + }).map((parts) => renderPushNote({ ...parts, range: result.range, request })) ); } - interact(diff: string, message: string): Future { - return this.promptAction(message).chain((action) => { + interact(diff: string, generated: GeneratedContent): Future { + return this.promptAction(generated.text).chain((action) => { switch (action) { case "commit": - return this.handleCommit(message); + return this.handleCommit(generated); case "commit_push": - return this.handleCommitAndPush(message); + return this.handleCommitAndPush(generated); case "regenerate": 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); + return this.handleAdjust(diff, generated); case "cancel": return Future.resolve(undefined); } @@ -140,59 +140,62 @@ class Commit { }); } - private handleCommit(message: string): Future { - return this.commit(message).map((stats) => { - process.stdout.write(stats); - p.outro(color.green("Committed successfully!")); - }); + private handleCommit(generated: GeneratedContent): Future { + return this.commit(generated.text).chain((stats) => + repo.findCommitMetadata().map((commit) => { + process.stdout.write(stats); + renderCommitNote({ commit, request: Just(generated.metadata) }); + p.outro(color.green("Committed successfully!")); + }) + ); } - private handleCommitAndPush(message: string): Future { - return this.commit(message) + private handleCommitAndPush(generated: GeneratedContent): Future { + return this.commit(generated.text) .chain((stats) => { process.stdout.write(stats); - return this.pushAfterCommit(); + return this.pushAfterCommit(Just(generated.metadata)); }) .map(() => { p.outro(color.green("Done!")); }); } - private pushAfterCommit(): Future { + private pushAfterCommit(request: Maybe): Future { return repo .hasUpstream() .chain((exists) => exists ? - this.push().chainRej((err) => (isNonFastForwardError(err) ? this.promptForceWithLease() : Future.reject(err))) - : this.promptPublishBranch() + this.push(request).chainRej((err) => (isNonFastForwardError(err) ? this.promptForceWithLease(request) : Future.reject(err))) + : this.promptPublishBranch(request) ); } - private promptPublishBranch(): Future { + private promptPublishBranch(request: Maybe): Future { return repo.getCurrentBranch().chain((branch) => Future.attemptP(async () => { const publish = await p.confirm({ message: `Branch '${branch}' has no upstream. Publish to origin?` }); return !(p.isCancel(publish) || !publish); - }).chain((shouldPublish) => (shouldPublish ? this.push(branch, true) : Future.resolve(undefined))) + }).chain((shouldPublish) => (shouldPublish ? this.push(request, branch, true) : Future.resolve(undefined))) ); } - private promptForceWithLease(): Future { + private promptForceWithLease(request: Maybe): Future { return Future.attemptP(async () => { const force = await p.confirm({ message: "Push was rejected (branch is behind remote). Force push with lease?" }); return !(p.isCancel(force) || !force); - }).chain((shouldForce) => (shouldForce ? this.push(undefined, false, true) : Future.resolve(undefined))); + }).chain((shouldForce) => (shouldForce ? this.push(request, undefined, false, true) : Future.resolve(undefined))); } - private handleAdjust(diff: string, message: string): Future { + private handleAdjust(diff: string, generated: GeneratedContent): Future { return this.promptAdjustment().chain((maybeAdj) => maybeAdj instanceof Nothing ? - this.interact(diff, message) - : this.refine(message, maybeAdj.value, diff).chain((refined) => this.interact(diff, refined)) + this.interact(diff, generated) + : this.refine(generated.text, maybeAdj.value, diff).chain((refined) => this.interact(diff, refined)) ); } diff --git a/src/domain/llm/router.ts b/src/domain/llm/router.ts index 231b253..73a6d27 100644 --- a/src/domain/llm/router.ts +++ b/src/domain/llm/router.ts @@ -1,4 +1,13 @@ -export { type GenerateContentParams, generateCommitMessage, refineCommitMessage }; +export { + type GenerateContentParams, + type GeneratedContent, + type LlmRequestMetadata, + type ModelRequestMetadata, + type ProviderGeneratedContent, + type TokenUsage, + generateCommitMessage, + refineCommitMessage +}; import { Future } from "@/libs/future"; import { type ProviderConfig, type CommitConvention } from "@/domain/config/config"; @@ -13,14 +22,64 @@ type GenerateContentParams = { readonly systemInstruction?: string; }; -const generateContent = (config: ProviderConfig, params: GenerateContentParams): Future => { +type TokenUsage = { + readonly input: Maybe; + readonly output: Maybe; + readonly total: Maybe; +}; + +type ModelRequestMetadata = { + readonly provider: ProviderConfig["provider"]; + readonly model: string; + readonly effort: string; +}; + +type LlmRequestMetadata = { + readonly durationMs: number; + readonly model: ModelRequestMetadata; + readonly tokens: Maybe; +}; + +type GeneratedContent = { + readonly text: string; + readonly metadata: LlmRequestMetadata; +}; + +type ProviderGeneratedContent = { + readonly text: string; + readonly tokens: Maybe; +}; + +const modelRequestMetadata = (config: ProviderConfig): ModelRequestMetadata => { + switch (config.provider) { + case "openai": + return { provider: config.provider, model: config.model, effort: config.effort.maybe("provider default", (effort) => effort) }; + case "gemini": + case "anthropic": + return { provider: config.provider, model: config.model, effort: config.effort.maybe("medium", (effort) => effort) }; + } +}; + +const withRequestMetadata = (config: ProviderConfig, f: Future): Future => { + const startedAt = Date.now(); + return f.map(({ text, tokens }) => ({ + text, + metadata: { + durationMs: Date.now() - startedAt, + model: modelRequestMetadata(config), + tokens + } + })); +}; + +const generateContent = (config: ProviderConfig, params: GenerateContentParams): Future => { switch (config.provider) { case "gemini": - return generateContentWithGemini(config, params); + return withRequestMetadata(config, generateContentWithGemini(config, params)); case "openai": - return generateContentWithOpenAI(config, params); + return withRequestMetadata(config, generateContentWithOpenAI(config, params)); case "anthropic": - return generateContentWithAnthropic(config, params); + return withRequestMetadata(config, generateContentWithAnthropic(config, params)); } }; @@ -29,7 +88,7 @@ const generateCommitMessage = ( diff: string, convention: CommitConvention, customTemplate: Maybe = Nothing() -): Future => generateContent(config, { prompt: getPrompt(diff, convention, customTemplate) }); +): Future => generateContent(config, { prompt: getPrompt(diff, convention, customTemplate) }); -const refineCommitMessage = (config: ProviderConfig, currentMessage: string, adjustment: string, diff: string): Future => +const refineCommitMessage = (config: ProviderConfig, currentMessage: string, adjustment: string, diff: string): Future => generateContent(config, getRefinePrompt({ diff, currentMessage, adjustment })); diff --git a/src/infra/llm/anthropic.ts b/src/infra/llm/anthropic.ts index 82206dc..c391218 100644 --- a/src/infra/llm/anthropic.ts +++ b/src/infra/llm/anthropic.ts @@ -3,7 +3,7 @@ export { generateContentWithAnthropic }; import Anthropic from "@anthropic-ai/sdk"; import { type Config, type AnthropicEffort } from "@/domain/config/config"; -import { type GenerateContentParams } from "@/domain/llm/router"; +import { type GenerateContentParams, type ProviderGeneratedContent, type TokenUsage } from "@/domain/llm/router"; import { Future } from "@/libs/future"; import { anthropicOAuthHeaders, CLAUDE_CODE_SYSTEM_PROMPT } from "@/infra/auth/anthropic"; import { absurd } from "@/libs/types"; @@ -20,6 +20,15 @@ const extractAnthropicText = (content: Anthropic.ContentBlock[]): string => .map((b) => b.text) .join(""); +const toTokenUsage = (usage: Anthropic.Usage): TokenUsage => { + const input = usage.input_tokens + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0); + return { + input: Just(input), + output: Just(usage.output_tokens), + total: Just(input + usage.output_tokens) + }; +}; + const buildParams = ( model: string, system: Maybe, @@ -42,31 +51,52 @@ const buildSetupTokenSystem = (instruction: Maybe): SystemParam => { type: "text", text } ]); -const callAnthropicWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => +const callAnthropicWithApiKey = ( + apiKey: string, + model: string, + effort: Maybe, + params: GenerateContentParams +): Future => Future.attemptP(async () => { - const client = new Anthropic({ apiKey }); + const client = new Anthropic({ apiKey, maxRetries: 3, timeout: 120_000 }); 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((message) => extractResponse({ text: Just(extractAnthropicText(message.content)) })); + .mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`, { cause: error })) + .chain((message) => + extractResponse({ text: Just(extractAnthropicText(message.content)) }).map((text) => ({ + text, + tokens: Just(toTokenUsage(message.usage)) + })) + ); const callAnthropicWithSetupToken = ( authToken: string, model: string, effort: Maybe, params: GenerateContentParams -): Future => +): Future => Future.attemptP(async () => { - const client = new Anthropic({ apiKey: null, authToken, defaultHeaders: anthropicOAuthHeaders() }); + const client = new Anthropic({ + apiKey: null, + authToken, + defaultHeaders: anthropicOAuthHeaders(), + maxRetries: 3, + timeout: 120_000 + }); const system = Just(buildSetupTokenSystem(fromOptional(params.systemInstruction))); 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((message) => extractResponse({ text: Just(extractAnthropicText(message.content)) })); + .mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`, { cause: error })) + .chain((message) => + extractResponse({ text: Just(extractAnthropicText(message.content)) }).map((text) => ({ + text, + tokens: Just(toTokenUsage(message.usage)) + })) + ); -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/gemini.ts b/src/infra/llm/gemini.ts index d5473d8..177cef4 100644 --- a/src/infra/llm/gemini.ts +++ b/src/infra/llm/gemini.ts @@ -3,7 +3,7 @@ export { type GeminiAuthCredentials, generateContentWithGemini, getAuthCredentia import { GoogleGenAI, ThinkingLevel, type Content, type GenerateContentConfig, type GenerateContentResponse, type GenerationConfig } from "@google/genai"; import { type Config, type OAuthTokens, type GeminiEffort } from "@/domain/config/config"; -import { type GenerateContentParams } from "@/domain/llm/router"; +import { type GenerateContentParams, type ProviderGeneratedContent, type TokenUsage } from "@/domain/llm/router"; import { Future } from "@/libs/future"; import { getAccessToken } from "@/infra/auth/google"; import { Just, Nothing, fromOptional, type Maybe } from "@/libs/maybe"; @@ -20,6 +20,21 @@ type OAuthRequestBody = { generationConfig?: GenerationConfig; }; +const toTokenUsage = (usage: GenerateContentResponse["usageMetadata"]): Maybe => + fromOptional(usage).map((u) => ({ + input: fromOptional(u.promptTokenCount), + output: fromOptional(u.candidatesTokenCount), + total: fromOptional(u.totalTokenCount) + })); + +const extractGeminiText = (response: GenerateContentResponse): string => + response.text ?? response.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("") ?? ""; + +const toGeneratedContent = (response: GenerateContentResponse): ProviderGeneratedContent => ({ + text: extractGeminiText(response), + tokens: toTokenUsage(response.usageMetadata) +}); + const getAuthCredentials = (config: Config): Maybe => { switch (config.ai.auth_method.type) { case "google_oauth": @@ -46,65 +61,55 @@ const buildOAuthBody = (effort: Maybe, params: GenerateContentPara return fromOptional(params.systemInstruction).maybe(core, (s) => ({ ...core, systemInstruction: { parts: [{ text: s }] } })); }; -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 => { +const parseOAuthResponse = async (response: Response): Promise => { if (!response.ok) throw new Error(`Gemini API error (${response.status}): ${await response.text()}`); - 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; + + // The REST OAuth path receives the same JSON wire shape that @google/genai maps + // to GenerateContentResponse for API-key calls. Response.json() cannot prove that + // shape to TypeScript, so this cast keeps both auth paths on one metadata mapper. + // If this breaks, compare the REST payload with the fields used below: + // response.text, candidates[].content.parts[].text, and usageMetadata token counts. + return toGeneratedContent((await response.json()) as GenerateContentResponse); }; -const generateContentWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => +const generateContentWithApiKey = ( + apiKey: string, + model: string, + effort: Maybe, + params: GenerateContentParams +): Future => Future.attemptP(async () => { - const ai = new GoogleGenAI({ apiKey }); - 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; + const ai = new GoogleGenAI({ + apiKey, + httpOptions: { timeout: 120_000, retryOptions: { attempts: 3 } } + }); + const response = await ai.models.generateContent({ model, contents: params.prompt, config: buildSDKConfig(effort, params) }); + return toGeneratedContent(response); }) .mapRej((error) => new Error(`Failed to create Gemini content: ${error instanceof Error ? error.message : String(error)}`)) - .chain((text) => extractResponse({ text: fromOptional(text) })); + .chain((content) => extractResponse({ text: fromOptional(content.text) }).map((text) => ({ ...content, text }))); const generateContentWithOAuth = ( tokens: OAuthTokens, model: string, effort: Maybe, params: GenerateContentParams -): Future => +): Future => getAccessToken(tokens).chain((accessToken) => Future.attemptP(async () => { - const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse`; + 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 accumulateSSEText(response); + return await parseOAuthResponse(response); }) .mapRej((error) => new Error(`Failed to create Gemini content: ${error instanceof Error ? error.message : String(error)}`)) - .chain((text) => extractResponse({ text: fromOptional(text) })) + .chain((content) => extractResponse({ text: fromOptional(content.text) }).map((text) => ({ ...content, text }))) ); -const generateContentWithGemini = (config: GeminiConfig, params: GenerateContentParams): Future => { +const generateContentWithGemini = (config: GeminiConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { case "api_key": return generateContentWithApiKey(config.auth_method.content, config.model, config.effort, params); diff --git a/src/infra/llm/openai.ts b/src/infra/llm/openai.ts index d34511d..e8bbf9b 100644 --- a/src/infra/llm/openai.ts +++ b/src/infra/llm/openai.ts @@ -3,13 +3,13 @@ export { generateContentWithOpenAI }; import OpenAI from "openai"; import { type Config, type OpenAIEffort } from "@/domain/config/config"; -import { type GenerateContentParams } from "@/domain/llm/router"; +import { type GenerateContentParams, type ProviderGeneratedContent, type TokenUsage } from "@/domain/llm/router"; import { Future } from "@/libs/future"; import { getOpenAIAccessToken } from "@/infra/auth/openai"; import { extractResponse } from "@/domain/llm/response-parser"; import { unsupportedAuth } from "@/domain/llm/auth-error"; import { absurd } from "@/libs/types"; -import { fromOptional, type Maybe } from "@/libs/maybe"; +import { Just, fromOptional, type Maybe } from "@/libs/maybe"; type OpenAIConfig = Extract; type StreamBundle = { @@ -30,6 +30,12 @@ const extractStreamText = (bundle: StreamBundle): Maybe => { const openaiReasoning = (effort: Maybe): Maybe => effort.map((e) => ({ effort: e })); +const toTokenUsage = (usage: OpenAI.Responses.ResponseUsage): TokenUsage => ({ + input: Just(usage.input_tokens), + output: Just(usage.output_tokens), + total: Just(usage.total_tokens) +}); + const buildStreamParams = (model: string, effort: Maybe, params: GenerateContentParams): OpenAI.Responses.ResponseCreateParamsStreaming => { const core: OpenAI.Responses.ResponseCreateParamsStreaming = { model, @@ -41,7 +47,12 @@ const buildStreamParams = (model: string, effort: Maybe, params: G return openaiReasoning(effort).maybe(core, (r) => ({ ...core, reasoning: r })); }; -const callOpenAIStream = (client: OpenAI, model: string, effort: Maybe, params: GenerateContentParams): Future => +const callOpenAIStream = ( + client: OpenAI, + model: string, + effort: Maybe, + params: GenerateContentParams +): Future => Future.attemptP(async () => { const stream = client.responses.stream(buildStreamParams(model, effort, params)); @@ -60,15 +71,34 @@ const callOpenAIStream = (client: OpenAI, model: string, effort: Maybe new Error(`Failed to create OpenAI response: ${error instanceof Error ? error.message : String(error)}`)) - .chain((bundle) => extractResponse({ text: extractStreamText(bundle) })); + .chain((bundle) => + extractResponse({ text: extractStreamText(bundle) }).map((text) => ({ + text, + tokens: fromOptional(bundle.response.usage).map(toTokenUsage) + })) + ); -const callOpenAIWithApiKey = (apiKey: string, model: string, effort: Maybe, params: GenerateContentParams): Future => - callOpenAIStream(new OpenAI({ apiKey }), model, effort, params); +const callOpenAIWithApiKey = ( + apiKey: string, + model: string, + effort: Maybe, + params: GenerateContentParams +): Future => callOpenAIStream(new OpenAI({ apiKey, maxRetries: 3, timeout: 120_000 }), 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 callOpenAIWithOAuth = ( + authToken: string, + model: string, + effort: Maybe, + params: GenerateContentParams +): Future => + callOpenAIStream( + new OpenAI({ baseURL: "https://chatgpt.com/backend-api/codex", apiKey: authToken, maxRetries: 3, timeout: 120_000 }), + model, + effort, + params + ); -const generateContentWithOpenAI = (config: OpenAIConfig, params: GenerateContentParams): Future => { +const generateContentWithOpenAI = (config: OpenAIConfig, params: GenerateContentParams): Future => { switch (config.auth_method.type) { case "api_key": return callOpenAIWithApiKey(config.auth_method.content, config.model, config.effort, params); diff --git a/src/infra/ui/push-note.ts b/src/infra/ui/push-note.ts index 43c3da3..f6a5aed 100644 --- a/src/infra/ui/push-note.ts +++ b/src/infra/ui/push-note.ts @@ -1,12 +1,20 @@ -export { renderPushNote, type PushMetadata }; +export { renderCommitNote, renderPushNote, type CommitNoteMetadata, type PushMetadata }; import * as p from "@clack/prompts"; import type { CommitMetadata, PushRange } from "@/infra/git/repo"; import type { PrLookup } from "@/infra/github/pr"; +import type { LlmRequestMetadata } from "@/domain/llm/router"; import type { Maybe } from "@/libs/maybe"; import { absurd } from "@/libs/types"; +type RequestMetadata = Maybe; + +type CommitNoteMetadata = { + commit: Maybe; + request: RequestMetadata; +}; + type PushMetadata = { commit: Maybe; localBranch: Maybe; @@ -14,10 +22,17 @@ type PushMetadata = { remoteUrl: Maybe; range: Maybe; pr: PrLookup; + request: RequestMetadata; }; const formatDate = (d: Date): string => d.toISOString().slice(0, 16).replace("T", " "); +const formatDuration = (ms: number): string => (ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`); + +const formatNumber = (n: number): string => n.toLocaleString("en-US"); + +const renderModelLine = (metadata: LlmRequestMetadata): string => `model ${metadata.model.model} with ${metadata.model.effort} effort`; + const renderPrLine = (lookup: PrLookup): string[] => { switch (lookup.type) { case "found": @@ -39,13 +54,40 @@ const renderCommitLines = (commit: Maybe): string[] => `date ${formatDate(value.date)}` ]); +const renderRequestLines = (request: RequestMetadata): string[] => + request.maybe([], (value) => [ + renderModelLine(value), + `request ${formatDuration(value.durationMs)}`, + value.tokens.maybe( + "tokens unavailable", + (tokens) => + `tokens input ${tokens.input.maybe("?", formatNumber)} output ${tokens.output.maybe("?", formatNumber)} total ${tokens.total.maybe("?", formatNumber)}` + ) + ]); + +const renderCommitNote = (m: CommitNoteMetadata): void => { + const body = [...renderCommitLines(m.commit), ...renderRequestLines(m.request)].join("\n"); + + if (!body) return; + + p.note(body, "Committed"); +}; + const renderPushNote = (m: PushMetadata): void => { 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), ...branchLine, ...baseLine, ...remoteLine, ...rangeLine, ...renderPrLine(m.pr)].join("\n"); + const body = [ + ...renderCommitLines(m.commit), + ...branchLine, + ...baseLine, + ...remoteLine, + ...rangeLine, + ...renderRequestLines(m.request), + ...renderPrLine(m.pr) + ].join("\n"); if (!body) return;