diff --git a/Sources/SwiftCommitGen/CommitGenOptions.swift b/Sources/SwiftCommitGen/CommitGenOptions.swift index 42bfa5b..e0b6d9f 100644 --- a/Sources/SwiftCommitGen/CommitGenOptions.swift +++ b/Sources/SwiftCommitGen/CommitGenOptions.swift @@ -21,6 +21,7 @@ struct CommitGenOptions { case detailed #if canImport(FoundationModels) + @available(macOS 26.0, *) var promptRepresentation: Prompt { Prompt { styleGuidance } } diff --git a/Sources/SwiftCommitGen/CommitGenTool.swift b/Sources/SwiftCommitGen/CommitGenTool.swift index 0463e4b..a069027 100644 --- a/Sources/SwiftCommitGen/CommitGenTool.swift +++ b/Sources/SwiftCommitGen/CommitGenTool.swift @@ -72,7 +72,11 @@ struct CommitGenTool { self.llmClient = OllamaClient(configuration: config) #if canImport(FoundationModels) case .foundationModels: - self.llmClient = FoundationModelsClient() + if #available(macOS 26.0, *) { + self.llmClient = FoundationModelsClient() + } else { + fatalError("FoundationModels backend requires macOS 26.0 or newer") + } #else case .foundationModels: fatalError("FoundationModels is not available on this platform") diff --git a/Sources/SwiftCommitGen/Core/BatchCombinationPromptBuilder.swift b/Sources/SwiftCommitGen/Core/BatchCombinationPromptBuilder.swift index d5448c4..bd1ce57 100644 --- a/Sources/SwiftCommitGen/Core/BatchCombinationPromptBuilder.swift +++ b/Sources/SwiftCommitGen/Core/BatchCombinationPromptBuilder.swift @@ -57,43 +57,22 @@ struct BatchCombinationPromptBuilder { "Produce one final commit subject (<= 50 characters) and an optional body that summarizes the full change set. Avoid repeating the batch headings—present the combined commit message only." ) - #if canImport(FoundationModels) - let userPrompt = Prompt { - for line in userLines { - line - } - } - - let systemPrompt = Instructions { - """ - You are an AI assistant merging multiple partial commit drafts into a single, well-structured commit message. - Preserve all important intent from the inputs, avoid redundancy, and keep the final subject concise (<= 50 characters). - The title should succinctly describe the change in a specific and informative manner. - Provide an optional body only when useful for additional context. - If a body is present, it should describe the _purpose_ of the change, not just _what_ was changed: focus on the reasoning behind the changes rather than a file-by-file summary. - - Be clear and concise, but do not omit critical information. - """ - "" - metadata.style.styleGuidance - } - #else - let userPrompt = PromptContent(userLines.joined(separator: "\n")) + // Always use PromptContent - LLM client will convert if needed + let userPrompt = PromptContent(userLines.joined(separator: "\n")) - let systemPrompt = PromptContent( - """ - You are an AI assistant merging multiple partial commit drafts into a single, well-structured commit message. - Preserve all important intent from the inputs, avoid redundancy, and keep the final subject concise (<= 50 characters). - The title should succinctly describe the change in a specific and informative manner. - Provide an optional body only when useful for additional context. - If a body is present, it should describe the _purpose_ of the change, not just _what_ was changed: focus on the reasoning behind the changes rather than a file-by-file summary. + let systemPrompt = PromptContent( + """ + You are an AI assistant merging multiple partial commit drafts into a single, well-structured commit message. + Preserve all important intent from the inputs, avoid redundancy, and keep the final subject concise (<= 50 characters). + The title should succinctly describe the change in a specific and informative manner. + Provide an optional body only when useful for additional context. + If a body is present, it should describe the _purpose_ of the change, not just _what_ was changed: focus on the reasoning behind the changes rather than a file-by-file summary. - Be clear and concise, but do not omit critical information. + Be clear and concise, but do not omit critical information. - \(metadata.style.styleGuidance) - """ - ) - #endif + \(metadata.style.styleGuidance) + """ + ) let characterCount = userLines.reduce(0) { $0 + $1.count + 1 } let estimatedTokens = PromptDiagnostics.tokenEstimate(forCharacterCount: characterCount) diff --git a/Sources/SwiftCommitGen/Core/CommitGenError.swift b/Sources/SwiftCommitGen/Core/CommitGenError.swift index 174edb9..084fc58 100644 --- a/Sources/SwiftCommitGen/Core/CommitGenError.swift +++ b/Sources/SwiftCommitGen/Core/CommitGenError.swift @@ -9,6 +9,7 @@ enum CommitGenError: Error { case modelTimedOut(timeout: TimeInterval) case modelGenerationFailed(message: String) case llmRequestFailed(reason: String) + case invalidBackend(String) case notImplemented } @@ -33,6 +34,8 @@ extension CommitGenError: LocalizedError { message case .llmRequestFailed(let reason): "LLM request failed: \(reason)" + case .invalidBackend(let message): + message case .notImplemented: "Commit generation is not implemented yet; future phases will add this capability." } diff --git a/Sources/SwiftCommitGen/Core/DiffSummarizer.swift b/Sources/SwiftCommitGen/Core/DiffSummarizer.swift index 6118873..f751719 100644 --- a/Sources/SwiftCommitGen/Core/DiffSummarizer.swift +++ b/Sources/SwiftCommitGen/Core/DiffSummarizer.swift @@ -53,6 +53,7 @@ struct ChangeSummary: Hashable, Codable { } #if canImport(FoundationModels) + @available(macOS 26.0, *) var promptRepresentation: Prompt { Prompt { for line in promptLines() { @@ -198,6 +199,7 @@ struct ChangeSummary: Hashable, Codable { } #if canImport(FoundationModels) + @available(macOS 26.0, *) var promptRepresentation: Prompt { Prompt { for line in promptLines() { diff --git a/Sources/SwiftCommitGen/Core/LLMClient.swift b/Sources/SwiftCommitGen/Core/LLMClient.swift index c0bd9b0..beb0a64 100644 --- a/Sources/SwiftCommitGen/Core/LLMClient.swift +++ b/Sources/SwiftCommitGen/Core/LLMClient.swift @@ -9,25 +9,9 @@ protocol LLMClient { func generateCommitDraft(from prompt: PromptPackage) async throws -> LLMGenerationResult } -#if canImport(FoundationModels) - @Generable(description: "A commit for changes made in a git repository.") -#endif /// Model representation for the subject/body pair returned by the language model. struct CommitDraft: Hashable, Codable, Sendable { - #if canImport(FoundationModels) - @Guide( - description: - "The title of a commit. It should be no longer than 50 characters and should summarize the contents of the changeset for other developers reading the commit history. It should describe WHAT was changed." - ) - #endif var subject: String - - #if canImport(FoundationModels) - @Guide( - description: - "A detailed description of the the purposes of the changes. It should describe WHY the changes were made." - ) - #endif var body: String? init(subject: String = "", body: String? = nil) { @@ -77,6 +61,35 @@ struct CommitDraft: Hashable, Codable, Sendable { } } +#if canImport(FoundationModels) + @available(macOS 26.0, *) + extension CommitDraft: Generable { + static var generationSchema: GenerationSchema { + GenerationSchema( + type: Self.self, + description: "A commit for changes made in a git repository.", + properties: [ + .init(name: "subject", type: String.self), + .init(name: "body", type: String?.self), + ] + ) + } + + init(_ content: GeneratedContent) throws { + self.subject = try content.value(forProperty: "subject") + self.body = try content.value(forProperty: "body") + } + + var generatedContent: GeneratedContent { + var props: [(String, any ConvertibleToGeneratedContent)] = [("subject", subject)] + if let body = body { + props.append(("body", body)) + } + return GeneratedContent(properties: props, uniquingKeysWith: { _, second in second }) + } + } +#endif + /// Wraps a generated draft alongside diagnostics gathered during inference. struct LLMGenerationResult: Sendable { var draft: CommitDraft @@ -85,6 +98,7 @@ struct LLMGenerationResult: Sendable { #if canImport(FoundationModels) /// Concrete LLM client backed by Apple's FoundationModels framework. + @available(macOS 26.0, *) struct FoundationModelsClient: LLMClient { /// Controls retry behavior and timeouts for generation requests. struct Configuration { @@ -125,9 +139,18 @@ struct LLMGenerationResult: Sendable { throw CommitGenError.modelUnavailable(reason: reason) } + // Convert PromptContent to FoundationModels types + guard let systemPromptContent = prompt.systemPrompt as? PromptContent, + let userPromptContent = prompt.userPrompt as? PromptContent + else { + throw CommitGenError.invalidBackend( + "FoundationModels backend expected PromptContent but received: System: \(type(of: prompt.systemPrompt)), User: \(type(of: prompt.userPrompt))" + ) + } + let session = LanguageModelSession( model: model, - instructions: prompt.systemPrompt + instructions: { Instructions { systemPromptContent.content } } ) var diagnostics = prompt.diagnostics @@ -135,7 +158,7 @@ struct LLMGenerationResult: Sendable { generating: CommitDraft.self, options: generationOptions ) { - prompt.userPrompt + Prompt { userPromptContent.content } } let usage = analyzeTranscriptEntries(response.transcriptEntries) @@ -279,16 +302,39 @@ struct OllamaClient: LLMClient { request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") + // Extract string content from prompts + #if canImport(FoundationModels) + // When FoundationModels is available, prompts could be either PromptContent or FoundationModels types + // depending on macOS version and runtime availability + let systemContent: String + let userContent: String + + if let systemPromptContent = prompt.systemPrompt as? PromptContent, + let userPromptContent = prompt.userPrompt as? PromptContent + { + systemContent = systemPromptContent.content + userContent = userPromptContent.content + } else { + // This should not happen if the builder correctly uses PromptContent for non-macOS 26+ systems + throw CommitGenError.invalidBackend( + "Ollama backend requires PromptContent, but received FoundationModels types (System: \(type(of: prompt.systemPrompt)), User: \(type(of: prompt.userPrompt))). Use FoundationModels backend instead." + ) + } + #else + let systemContent = prompt.systemPrompt.content + let userContent = prompt.userPrompt.content + #endif + // Structure the messages properly let messages: [[String: String]] = [ [ "role": "system", - "content": prompt.systemPrompt.content, + "content": systemContent, ], [ "role": "user", "content": """ - \(prompt.userPrompt.content) + \(userContent) IMPORTANT: You must respond with ONLY valid JSON in this exact format, with no additional text before or after: { @@ -314,8 +360,8 @@ struct OllamaClient: LLMClient { // Log the request if verbose logging is enabled configuration.logger?.debug { - let systemPreview = prompt.systemPrompt.content.prefix(200) - let userPreview = prompt.userPrompt.content.prefix(800) + let systemPreview = systemContent.prefix(200) + let userPreview = userContent.prefix(800) return """ 📤 Ollama Request to \(configuration.model): ┌─ System Prompt (first 200 chars): @@ -367,7 +413,7 @@ struct OllamaClient: LLMClient { promptTokens = promptEvalCount } else { promptTokens = PromptDiagnostics.tokenEstimate( - forCharacterCount: prompt.systemPrompt.content.count + prompt.userPrompt.content.count + forCharacterCount: systemContent.count + userContent.count ) } diff --git a/Sources/SwiftCommitGen/Core/PromptBuilder.swift b/Sources/SwiftCommitGen/Core/PromptBuilder.swift index 40b82bd..5bada19 100644 --- a/Sources/SwiftCommitGen/Core/PromptBuilder.swift +++ b/Sources/SwiftCommitGen/Core/PromptBuilder.swift @@ -49,7 +49,18 @@ struct PromptMetadata { includeUnstagedChanges ? "staged + unstaged changes" : "staged changes only" } + // Always available version for PromptContent + var asPromptContent: PromptContent { + PromptContent { + "Repository: \(repositoryName)" + "Branch: \(branchName)" + "Scope: \(scopeDescription)" + "Style: \(style.styleGuidance)" + } + } + #if canImport(FoundationModels) + @available(macOS 26.0, *) var promptRepresentation: Prompt { Prompt { "Repository: \(repositoryName)" @@ -60,12 +71,7 @@ struct PromptMetadata { } #else var promptContent: PromptContent { - PromptContent { - "Repository: \(repositoryName)" - "Branch: \(branchName)" - "Scope: \(scopeDescription)" - "Style: \(style.styleGuidance)" - } + asPromptContent } #endif } @@ -166,8 +172,8 @@ struct PromptDiagnostics: Codable, Sendable { /// Bundles the full prompt payload and derived diagnostics for a generation request. struct PromptPackage { #if canImport(FoundationModels) - var systemPrompt: Instructions - var userPrompt: Prompt + var systemPrompt: Any // Instructions on macOS 26+, PromptContent otherwise + var userPrompt: Any // Prompt on macOS 26+, PromptContent otherwise #else var systemPrompt: PromptContent var userPrompt: PromptContent @@ -185,11 +191,56 @@ struct PromptPackage { let additionalLines = contextLineCount + 2 // blank separator + heading #if canImport(FoundationModels) - let augmentedUserPrompt = Prompt { - userPrompt - "" - "Additional context from user:" - trimmed + if #available(macOS 26.0, *) { + let augmentedUserPrompt = Prompt { + userPrompt as! Prompt + "" + "Additional context from user:" + trimmed + } + + var updatedDiagnostics = diagnostics + let additionalCharacters = + trimmed.count + + "Additional context from user:".count + let newlineCharacters = additionalLines // account for line breaks + updatedDiagnostics.recordAdditionalUserContext( + lineCount: additionalLines, + characterCount: additionalCharacters + newlineCharacters + ) + + return PromptPackage( + systemPrompt: systemPrompt, + userPrompt: augmentedUserPrompt, + diagnostics: updatedDiagnostics + ) + } else { + // For macOS < 26, fall through to PromptContent handling + let promptContent = userPrompt as! PromptContent + let augmentedUserPrompt = PromptContent( + """ + \(promptContent.content) + + Additional context from user: + \(trimmed) + """ + ) + + var updatedDiagnostics = diagnostics + let additionalCharacters = + trimmed.count + + "Additional context from user:".count + let newlineCharacters = additionalLines + updatedDiagnostics.recordAdditionalUserContext( + lineCount: additionalLines, + characterCount: additionalCharacters + newlineCharacters + ) + + return PromptPackage( + systemPrompt: systemPrompt, + userPrompt: augmentedUserPrompt, + diagnostics: updatedDiagnostics + ) } #else let augmentedUserPrompt = PromptContent( @@ -200,23 +251,23 @@ struct PromptPackage { \(trimmed) """ ) - #endif - var updatedDiagnostics = diagnostics - let additionalCharacters = - trimmed.count - + "Additional context from user:".count - let newlineCharacters = additionalLines // account for line breaks - updatedDiagnostics.recordAdditionalUserContext( - lineCount: additionalLines, - characterCount: additionalCharacters + newlineCharacters - ) + var updatedDiagnostics = diagnostics + let additionalCharacters = + trimmed.count + + "Additional context from user:".count + let newlineCharacters = additionalLines // account for line breaks + updatedDiagnostics.recordAdditionalUserContext( + lineCount: additionalLines, + characterCount: additionalCharacters + newlineCharacters + ) - return PromptPackage( - systemPrompt: systemPrompt, - userPrompt: augmentedUserPrompt, - diagnostics: updatedDiagnostics - ) + return PromptPackage( + systemPrompt: systemPrompt, + userPrompt: augmentedUserPrompt, + diagnostics: updatedDiagnostics + ) + #endif } } @@ -261,7 +312,8 @@ struct DefaultPromptBuilder: PromptBuilder { } func makePrompt(summary: ChangeSummary, metadata: PromptMetadata) -> PromptPackage { - let system = buildSystemPrompt(style: metadata.style) + // Always use PromptContent types - the LLM client will convert to FoundationModels types if needed + let system = buildSystemPromptAsContent(style: metadata.style) var fileLimit = min(maxFiles, summary.fileCount) var snippetLimit = adjustedSnippetLimit( @@ -275,9 +327,10 @@ struct DefaultPromptBuilder: PromptBuilder { displaySummary: displaySummary, hintLimit: hintThreshold ) - var isCompacted = displaySummary.fileCount < summary.fileCount || snippetLimit < maxSnippetLines + var isCompacted = + displaySummary.fileCount < summary.fileCount || snippetLimit < maxSnippetLines - var user = buildUserPrompt( + var user = buildUserPromptAsContent( displaySummary: displaySummary, fullSummary: summary, metadata: metadata, @@ -309,7 +362,7 @@ struct DefaultPromptBuilder: PromptBuilder { ) isCompacted = displaySummary.fileCount < summary.fileCount || snippetLimit < maxSnippetLines - user = buildUserPrompt( + user = buildUserPromptAsContent( displaySummary: displaySummary, fullSummary: summary, metadata: metadata, @@ -346,6 +399,7 @@ struct DefaultPromptBuilder: PromptBuilder { } #if canImport(FoundationModels) + @available(macOS 26.0, *) private func buildSystemPrompt(style: CommitGenOptions.PromptStyle) -> Instructions { Instructions { """ @@ -391,6 +445,29 @@ struct DefaultPromptBuilder: PromptBuilder { } #endif + // Version that always returns PromptContent for use when FoundationModels is available but not macOS 26+ + private func buildSystemPromptAsContent(style: CommitGenOptions.PromptStyle) -> PromptContent { + PromptContent( + """ + You're an AI assistant whose job is to concisely summarize code changes into short, useful commit messages, with a title and a description. + A changeset is given in the git diff output format, affecting one or multiple files. + + The commit title should be no longer than 50 characters and should summarize the contents of the changeset for other developers reading the commit history. + The commit description can be longer, and should provide more context about the changeset, including why the changeset is being made, and any other relevant information. + The commit description is optional, so you can omit it if the changeset is small enough that it can be described in the commit title or if you don't have enough context. + + Be brief and concise. + + DO NOT include a description of changes in "lock" files from dependency managers like npm, yarn, or pip (and others), unless those are the only changes in the commit. + + When more explanation is helpful, provide a short body with full sentences. + Leave the body empty when the subject already captures the change or the context is unclear. + + \(style.styleGuidance) + """ + ) + } + private func adjustedSnippetLimit(totalFiles: Int, configuredLimit: Int) -> Int { var limit = configuredLimit @@ -405,6 +482,7 @@ struct DefaultPromptBuilder: PromptBuilder { } #if canImport(FoundationModels) + @available(macOS 26.0, *) private func buildUserPrompt( displaySummary: ChangeSummary, fullSummary: ChangeSummary, @@ -413,7 +491,7 @@ struct DefaultPromptBuilder: PromptBuilder { remainder: RemainderContext ) -> Prompt { Prompt { - metadata + metadata.promptRepresentation "Totals: \(fullSummary.fileCount) files; +\(fullSummary.totalAdditions) / -\(fullSummary.totalDeletions)" if isCompacted { @@ -447,7 +525,7 @@ struct DefaultPromptBuilder: PromptBuilder { } } - displaySummary + displaySummary.promptRepresentation } } #else @@ -508,6 +586,63 @@ struct DefaultPromptBuilder: PromptBuilder { } #endif +// Version that always returns PromptContent for use when FoundationModels is available but not macOS 26+ +private func buildUserPromptAsContent( + displaySummary: ChangeSummary, + fullSummary: ChangeSummary, + metadata: PromptMetadata, + isCompacted: Bool, + remainder: RemainderContext +) -> PromptContent { + var lines: [String] = [] + + lines.append(metadata.asPromptContent.content) + lines.append( + "Totals: \(fullSummary.fileCount) files; +\(fullSummary.totalAdditions) / -\(fullSummary.totalDeletions)" + ) + + if isCompacted { + lines.append( + "Context trimmed to stay within the model window; prioritize the most impactful changes." + ) + } + + if remainder.count > 0 { + lines.append( + "Showing first \(displaySummary.fileCount) files (of \(fullSummary.fileCount)); remaining \(remainder.count) files contribute +\(remainder.additions) / -\(remainder.deletions)." + ) + + for entry in remainder.kindBreakdown.prefix(4) { + lines.append(" more: \(entry.count) \(entry.kind) file(s)") + } + + if remainder.generatedCount > 0 { + lines.append( + " note: \(remainder.generatedCount) generated file(s) omitted per .gitattributes" + ) + } + + if !remainder.hintFiles.isEmpty { + if remainder.remainingNonGeneratedCount > remainder.hintFiles.count { + lines.append(" showing \(remainder.hintFiles.count) representative paths:") + } + for file in remainder.hintFiles { + let descriptor = [ + file.kind, + locationDescription(file.location), + file.isBinary ? "binary" : nil, + file.isGenerated ? "generated" : nil, + ].compactMap { $0 }.joined(separator: ", ") + lines.append(" • \(file.path) [\(descriptor)]") + } + } + } + + lines.append(contentsOf: displaySummary.promptLines()) + + return PromptContent(lines.joined(separator: "\n")) +} + private func trimSummary( _ summary: ChangeSummary, fileLimit: Int,