diff --git a/src/ai.ts b/src/ai.ts index 4c96662..744da83 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -1,29 +1,20 @@ -import type { Config } from "./types"; +import type { Config, StreamCallbacks } from "./types"; interface ChatMessage { role: "system" | "user" | "assistant"; content: string; } -interface ChatCompletionResponse { - choices?: Array<{ - message?: { - content?: string | null; - }; - finish_reason?: string; - }>; - error?: { - message?: string; - type?: string; - code?: string; - }; -} - const MAX_RETRIES = 3; -const RETRY_DELAY = 1000; +const RETRY_DELAY_MS = 1000; + +async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} function cleanMessage(raw: string): string { let msg = raw.trim(); + // Strip code fences if the whole response is wrapped if (msg.startsWith("```") && msg.endsWith("```")) { const lines = msg.split("\n"); if (lines.length > 2) { @@ -35,16 +26,14 @@ function cleanMessage(raw: string): string { return msg; } -async function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - export async function callAI( config: Config, systemPrompt: string, userPrompt: string, + callbacks?: StreamCallbacks, ): Promise { const url = `${config.apiBase.replace(/\/$/, "")}/chat/completions`; + const stream = callbacks != null; const messages: ChatMessage[] = [ { role: "system", content: systemPrompt }, @@ -64,45 +53,45 @@ export async function callAI( max_tokens: config.maxTokens, temperature: config.temperature, messages, + stream, }), }); if (!response.ok) { const text = await response.text(); if (response.status === 429 && attempt < MAX_RETRIES) { - await sleep(RETRY_DELAY * attempt); + await sleep(RETRY_DELAY_MS * attempt); continue; } throw new Error(`API request failed (${response.status}): ${text}`); } - const data = (await response.json()) as ChatCompletionResponse; + if (stream && response.body) { + return await readStream(response.body, callbacks!); + } + + const data = (await response.json()) as { + choices?: Array<{ message?: { content?: string | null }; finish_reason?: string }>; + error?: { message?: string; type?: string; code?: string }; + }; if (data.error) { - throw new Error( - `API error: ${data.error.message ?? JSON.stringify(data.error)}`, - ); + throw new Error(`API error: ${data.error.message ?? JSON.stringify(data.error)}`); } const raw = data.choices?.[0]?.message?.content; const finishReason = data.choices?.[0]?.finish_reason; - if (raw && raw.trim()) { - return raw; - } + if (raw && raw.trim()) return raw; if (finishReason === "length") { - throw new Error( - "Response truncated (max_tokens too low). Try increasing GAI_MAX_TOKENS.", - ); + throw new Error("Response truncated (max_tokens too low). Try increasing GAI_MAX_TOKENS."); } - if (finishReason === "content_filter") { throw new Error("Response blocked by content filter."); } - if (attempt < MAX_RETRIES) { - await sleep(RETRY_DELAY * attempt); + await sleep(RETRY_DELAY_MS * attempt); continue; } @@ -113,21 +102,70 @@ export async function callAI( if (attempt >= MAX_RETRIES) throw err; if (err instanceof Error && err.message.startsWith("API error")) throw err; if (err instanceof Error && err.message.includes("max_tokens")) throw err; - if (err instanceof Error && err.message.includes("content filter")) - throw err; - await sleep(RETRY_DELAY * attempt); + if (err instanceof Error && err.message.includes("content filter")) throw err; + await sleep(RETRY_DELAY_MS * attempt); } } throw new Error("Failed to generate response"); } +async function readStream(body: ReadableStream, callbacks: StreamCallbacks): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let fullText = ""; + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + // Keep the last potentially incomplete line + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("data:")) continue; + + const data = trimmed.slice(5).trim(); + if (data === "[DONE]") continue; + + try { + const parsed = JSON.parse(data) as { + choices?: Array<{ delta?: { content?: string }; finish_reason?: string }>; + }; + const token = parsed.choices?.[0]?.delta?.content; + if (token) { + fullText += token; + callbacks.onToken?.(token); + } + const finishReason = parsed.choices?.[0]?.finish_reason; + if (finishReason === "length") { + callbacks.onError?.(new Error("Response truncated (max_tokens too low).")); + } + } catch { + // Skip unparseable SSE lines + } + } + } + } finally { + reader.releaseLock(); + } + + callbacks.onDone?.(fullText); + return fullText; +} + export async function generateCommitMessage( config: Config, systemPrompt: string, userPrompt: string, + callbacks?: StreamCallbacks, ): Promise { - const raw = await callAI(config, systemPrompt, userPrompt); + const raw = await callAI(config, systemPrompt, userPrompt, callbacks); return cleanMessage(raw); } @@ -135,8 +173,9 @@ export async function generatePRMessage( config: Config, systemPrompt: string, userPrompt: string, + callbacks?: StreamCallbacks, ): Promise<{ title: string; body: string }> { - const raw = await callAI(config, systemPrompt, userPrompt); + const raw = await callAI(config, systemPrompt, userPrompt, callbacks); const cleaned = cleanMessage(raw); const lines = cleaned.split("\n"); @@ -148,6 +187,5 @@ export async function generatePRMessage( } const body = lines.slice(bodyStart).join("\n").trim(); - return { title, body }; } diff --git a/src/prompt.ts b/src/prompt.ts index 4090f1a..16327e3 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -1,5 +1,7 @@ import type { PRContext, ProjectContext } from "./types"; +// ── Commit System Prompt ────────────────────────────────────────────── + export const SYSTEM_PROMPT = `You are an expert at writing concise, meaningful git commit messages following the Conventional Commits specification. Format: (): @@ -19,29 +21,17 @@ Rules: export function buildPrompt(context: ProjectContext): string { const parts: string[] = []; - if ( - context.packageDescription || - context.readme || - context.structure - ) { + if (context.packageDescription || context.readme || context.structure) { parts.push("## Project Context"); - if (context.packageDescription) { - parts.push(`Description: ${context.packageDescription}`); - } - if (context.structure) { - parts.push(`Structure: ${context.structure}`); - } - if (context.readme) { - parts.push(`README:\n${context.readme}`); - } + if (context.packageDescription) parts.push(`Description: ${context.packageDescription}`); + if (context.structure) parts.push(`Structure: ${context.structure}`); + if (context.readme) parts.push(`README:\n${context.readme}`); parts.push(""); } if (context.recentCommits.length > 0) { parts.push("## Recent Commits (for style reference)"); - for (const c of context.recentCommits) { - parts.push(c); - } + for (const c of context.recentCommits) parts.push(c); parts.push(""); } @@ -55,6 +45,8 @@ export function buildPrompt(context: ProjectContext): string { return parts.join("\n"); } +// ── PR System Prompt ─────────────────────────────────────────────────── + export const PR_SYSTEM_PROMPT = `You are an expert at writing clear, concise pull request titles and descriptions. Format: @@ -74,21 +66,11 @@ Rules: export function buildPRPrompt(context: PRContext): string { const parts: string[] = []; - if ( - context.packageDescription || - context.readme || - context.structure - ) { + if (context.packageDescription || context.readme || context.structure) { parts.push("## Project Context"); - if (context.packageDescription) { - parts.push(`Description: ${context.packageDescription}`); - } - if (context.structure) { - parts.push(`Structure: ${context.structure}`); - } - if (context.readme) { - parts.push(`README:\n${context.readme}`); - } + if (context.packageDescription) parts.push(`Description: ${context.packageDescription}`); + if (context.structure) parts.push(`Structure: ${context.structure}`); + if (context.readme) parts.push(`README:\n${context.readme}`); parts.push(""); } @@ -99,9 +81,7 @@ export function buildPRPrompt(context: PRContext): string { if (context.branchCommits.length > 0) { parts.push("## Commits on This Branch"); - for (const c of context.branchCommits) { - parts.push(c); - } + for (const c of context.branchCommits) parts.push(c); parts.push(""); } @@ -110,9 +90,141 @@ export function buildPRPrompt(context: PRContext): string { parts.push(context.diff); parts.push("```"); parts.push(""); - parts.push( - "Generate a pull request title and brief body for the above changes.", - ); + parts.push("Generate a pull request title and brief body for the above changes."); return parts.join("\n"); } + +// ── Explain Prompt ───────────────────────────────────────────────────── + +export const EXPLAIN_SYSTEM_PROMPT = `You are an expert software engineer explaining code changes in plain, accessible language. + +Given a git diff, explain: +1. WHAT changed at a high level (1 sentence summary) +2. WHY these changes matter (what problem they solve or what they enable) +3. A brief breakdown of the key changes (bullet points, one per file/module) + +Rules: +- Be concise but thorough +- Use plain language suitable for both junior and senior engineers +- Focus on the intent and impact, not just restating the diff +- Do NOT use markdown headings (no ##, ###). Use bold text markers like **Section:** instead. +- Keep each bullet point to 1-2 lines +- If the diff is trivial, say so and keep the explanation short`; + +export function buildExplainPrompt(diff: string): string { + const parts: string[] = []; + parts.push("## Changes to Explain"); + parts.push("```diff"); + parts.push(diff); + parts.push("```"); + parts.push(""); + parts.push("Explain these changes in plain language as described."); + return parts.join("\n"); +} + +// ── Review Prompt ────────────────────────────────────────────────────── + +export const REVIEW_SYSTEM_PROMPT = `You are a senior software engineer performing a thorough but friendly code review. + +Review the following code changes and provide feedback in these categories: +1. **Bugs & Logic Errors** — actual bugs, off-by-one errors, null safety, edge cases +2. **Code Quality** — readability, naming, duplication, complexity +3. **Performance** — inefficient patterns, unnecessary allocations, N+1 queries +4. **Security** — injection risks, exposed secrets, unsafe operations +5. **Suggestions** — concrete improvements with code snippets where helpful + +Rules: +- Be constructive, not harsh. Use "consider" and "suggest" instead of "you should". +- Prioritize by severity. Mention critical issues first. +- If the code looks great, say so! Don't fabricate issues. +- Keep feedback actionable — every issue should have a clear suggestion. +- Use **bold** for section headers and \`code\` for code references. +- Do NOT output a concluding summary paragraph. End with the last suggestion.`; + +export function buildReviewPrompt(diff: string, strictness: "lenient" | "normal" | "strict"): string { + const strictnessHints: Record = { + lenient: "Focus only on major issues. Skip minor style nits.", + normal: "Provide balanced feedback covering all categories.", + strict: "Be thorough. Flag even minor issues and style inconsistencies.", + }; + + const parts: string[] = []; + parts.push(`Review strictness: ${strictnessHints[strictness]}`); + parts.push(""); + parts.push("## Code Changes to Review"); + parts.push("```diff"); + parts.push(diff); + parts.push("```"); + parts.push(""); + parts.push("Please review the above changes."); + return parts.join("\n"); +} + +// ── Changelog Prompt ─────────────────────────────────────────────────── + +export const CHANGELOG_SYSTEM_PROMPT = `You are an expert at writing clear, user-facing changelogs from git commit history. + +Given a list of commits, generate a changelog organized by type: +- **Features** (feat commits) +- **Bug Fixes** (fix commits) +- **Improvements** (refactor, perf, style commits) +- **Documentation** (docs commits) +- **Chores & Maintenance** (chore, build, ci, test commits) + +Rules: +- Group by type, with the heading in **bold** +- Each entry should be a single line describing the change in user-friendly language +- Translate technical commit messages into language a user would understand +- Skip merge commits and trivial chore commits if they don't add value +- If a type has no entries, omit that section +- Output ONLY the changelog text, no preamble or markdown code blocks`; + +export function buildChangelogPrompt(commits: string[], from?: string, to?: string): string { + const parts: string[] = []; + const range = from ? `from ${from}${to ? ` to ${to}` : " to HEAD"}` : ""; + parts.push(range ? `Generate a changelog for commits ${range}.` : "Generate a changelog from the following commits."); + parts.push(""); + parts.push("## Commits"); + for (const c of commits) parts.push(c); + parts.push(""); + parts.push("Generate a changelog from these commits."); + return parts.join("\n"); +} + +// ── Suggest Prompt ───────────────────────────────────────────────────── + +export const SUGGEST_SYSTEM_PROMPT = `You are an expert at suggesting git branch names and commit types based on code changes. + +For branch name suggestions: +- Use format: / +- Types: feat, fix, refactor, docs, chore, perf, test +- Description should be 2-4 hyphenated words +- Provide exactly 3 suggestions, one per line + +For commit type suggestions: +- Return exactly one Conventional Commit type that best matches the changes +- Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert +- Output ONLY the type name`; + +export function buildSuggestBranchPrompt(diff: string): string { + const parts: string[] = []; + parts.push("## Changes"); + parts.push("```diff"); + parts.push(diff); + parts.push("```"); + parts.push(""); + parts.push("Suggest 3 branch names for these changes. Output one per line, no numbering."); + return parts.join("\n"); +} + +export function buildSuggestTypePrompt(diff: string): string { + const parts: string[] = []; + parts.push("## Changes"); + parts.push("```diff"); + parts.push(diff); + parts.push("```"); + parts.push(""); + parts.push("What Conventional Commit type best describes these changes? Output ONLY the type name."); + return parts.join("\n"); +}