diff --git a/.gitignore b/.gitignore index 62e9ff1..aa499b5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules .env Session.vim .npmrc +PR \ No newline at end of file diff --git a/README.md b/README.md index 39fa448..c09abb5 100644 --- a/README.md +++ b/README.md @@ -32,16 +32,15 @@ npm i ## link the package globally npm link ``` - -## Setup - -Keep your API keys ready: a primary API key and an optional fallback key (recommended). +Keep your API keys ready for cloud providers. +If you use Ollama locally, no API key is required. The fallback is used only if the primary provider fails. Recommended free/accessible providers: - Groq: https://console.groq.com/ - Gemini: https://aistudio.google.com/ +- Ollama (local): https://ollama.ai/ — run local models without API keys. `scom` can automatically discover installed Ollama models from your local server. Run the interactive setup: @@ -52,7 +51,7 @@ scom setup You will be asked to choose a primary agent and an optional fallback agent. ## Usage - + Make some changes in your project, stage the files you want to include, and run: ```bash @@ -115,13 +114,17 @@ Available commit styles: Run `scom model` to open the interactive model chooser. This command updates your global primary and fallback models, then saves them back to your config file. -It uses the API keys already stored in your config when they are available. -This command assumes you aleady have the API keys set, so it won't ask for them again. +It uses the API keys already stored in your config when available. + If you want to change the keys, you can edit the config file directly or re-run `scom setup`. +When using Ollama, no API key is required. `scom` uses the default local Ollama endpoint unless `BASE_URL` is overridden. + +During generation, if the configured Ollama model is unavailable locally, `scom` can automatically fall back to an installed local model. + ## Example config (.scom.conf) -The setup writes a `.scom.conf` file in key=value format. Example content: +The setup writes a `.scom.conf` file in key=value format. If you run a local Ollama server, set `BASE_URL` to its base URL (for example `http://localhost:11434`). Example content: ```ini GEMINI_API_KEY=your-gemini-key @@ -136,6 +139,4 @@ defaultCommitStyle=adaptive ## Feature requests -Please open feature requests or bug reports on the project's GitHub repository (create a new issue with a clear title and reproduction steps). - ---- +Please open feature requests or bug reports on the project's GitHub repository (create a new issue with a clear title and reproduction steps). \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 64e7134..afce6eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sweet-commit", - "version": "2.3.1", + "version": "2.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sweet-commit", - "version": "2.3.1", + "version": "2.3.4", "license": "GNU General Public License v3.0", "dependencies": { "@clack/prompts": "^0.11.0", diff --git a/src/ai.js b/src/ai.js index 074e365..fde6322 100644 --- a/src/ai.js +++ b/src/ai.js @@ -7,12 +7,13 @@ export async function generateCommitMessage( diff, style = "adaptive", humanLikeCommit = true, + refinementNote = "", ) { - if (!agent || !agent.apiKey) { + if (!agent || (!agent.apiKey && agent.provider !== "ollama")) { throw new Error("No AI agent configured with a valid API key.") } - const prompt = buildPrompt(diff, style, humanLikeCommit) + const prompt = buildPrompt(diff, style, humanLikeCommit, refinementNote) const primaryModel = String(agent.model || "").trim() const fallbackModel = String(agent.fallbackModel || "").trim() diff --git a/src/ai/prompt.js b/src/ai/prompt.js index d65034a..6401000 100644 --- a/src/ai/prompt.js +++ b/src/ai/prompt.js @@ -4,88 +4,207 @@ function buildToneRules(humanLikeCommit) { } return ` -Tone: -- Keep it natural and concise. -- Do not sound like a release note or an AI assistant. -- Do not use phrases like "This commit" or "AI-generated". -- Keep the subject lowercase after the colon. -- Do not end the subject with a period.` + Tone: + - Keep it natural and concise. + - Do not sound like a release note or an AI assistant. + - Do not use phrases like "This commit" or "AI-generated". + - Keep the subject lowercase after the colon. + - Do not end the subject with a period. + ` } function buildOutputContract(style) { if (style === "short") { return ` -Output contract: -- Output plain text only. -- Exactly one subject line. -- Subject format: type(scope): description or type: description. -- Subject length <= 72 characters. -- No body unless absolutely necessary.` + Output contract: + - Output plain text only. + - Exactly one subject line. + - Subject format: type(scope): description or type: description. + - Subject length <= 72 characters. + - No body unless absolutely necessary. + ` } if (style === "detailed") { return ` -Output contract: -- Output plain text only. -- Subject first line in conventional commit format. -- Add a blank line, then body bullets only. -- 2 to 6 bullets, each starting with '- '. -- Each bullet describes one concrete change or reason. -- No paragraphs.` + Output contract: + - Output plain text only. + - Subject first line in conventional commit format. + - Add a blank line, then body bullets only. + - 2 to 6 bullets, each starting with '- '. + - Each bullet describes one concrete change or reason. + - No paragraphs. + ` } return ` -Output contract: -- Output plain text only. -- Subject first line in conventional commit format. -- If the diff is small/simple, return subject only. -- If the diff is medium/large, add a blank line and 1 to 4 body bullets. -- Body bullets only, no paragraphs.` + Output contract: + - Output plain text only. + - Subject first line in conventional commit format. + - If the diff is small/simple, return subject only. + - If the diff is medium/large, add a blank line and 1 to 4 body bullets. + - Body bullets only, no paragraphs. + ` +} + +function buildRefinementOutputContract(refinementNote, style) { + const note = String(refinementNote || "").trim() + + if (!note) { + return "" + } + + if (style === "short") { + return ` + Refinement output contract (short mode): + - Output plain text only. + - Exactly one subject line (conventional commit format). + - Do not include a body or bullets. + ` + } + + return ` + Refinement output contract: + - Follow the user's guidance first. + - Return a subject line plus 2 to 4 body bullets. + - Bullets should stay close to the requested framing and not broaden into a different feature. + ` } function buildCoreRules() { return ` -Rules: -- Choose the most specific type from: feat, fix, refactor, perf, docs, test, build, ci, chore, revert. -- Scope should match the main area changed (module, package, feature, command). -- Use imperative mood. -- Describe what changed and why, not implementation trivia. -- Mention breaking changes only if the diff clearly indicates one. -- Avoid vague text like "update files" or "misc changes". -- Do not include code fences, quotes, headings, or explanations outside the commit message.` + Rules: + - Choose the most specific type from: feat, fix, refactor, perf, docs, test, build, ci, chore, revert. + - Scope should match the main area changed (module, package, feature, command). + - Scope must be a short logical area (for example: ai, cli, config, model, git, setup). + - Never use file paths, filenames, extensions, or slashes in scope. + - Use imperative mood. + - Describe what changed and why, not implementation trivia. + - Mention breaking changes only if the diff clearly indicates one. + - Avoid vague text like "update files" or "misc changes". + - Do not include code fences, quotes, headings, or explanations outside the commit message. +` +} + +function buildDecisionProtocol() { + return ` + Decision protocol (follow in order): + 1) Read the full diff and infer the dominant change intent across all changed files. + 2) Select type by impact priority: + - If there is a clear new capability/behavior, use feat. + - Else if there is a clear bug/compatibility correction, use fix. + - Else if there is a clear performance gain, use perf. + - Else if behavior is preserved and structure is improved, use refactor. + - Else use docs/test/build/ci/chore/revert only when clearly dominant. + 3) Select scope: + - If one subsystem clearly dominates, use that subsystem token. + - If multiple subsystems are equally affected, use no scope or a broad scope like core. + - Never pick scope from a raw file path. + 4) Write one precise subject line that summarizes the whole changeset, not a single file. + + If guidance is present: + - Let the guidance determine the framing before considering the diff. + - Do not independently search for a broader main feature. + - Do not reframe the commit around repository-level summaries or downstream effects. + - Interpret the framing focus semantically, not literally. + - Describe the behavioral or functional change implied by the framing focus. + - Avoid mentioning filenames unless they are genuinely part of the feature itself. + + If uncertain: + - Prefer a conservative, accurate message over a specific but wrong one. + - Default type priority: feat > fix > perf > refactor > chore. + - If still uncertain, avoid scope and return type: description. +` +} + +function buildQualityGate() { + return ` + Before final output, silently verify: + - The subject reflects the overall diff, not one file. + - Scope is valid and not a file path. + - Wording is concrete and not generic. + - The line does not match any example text exactly. + - Output matches the required style contract exactly. + - Return only the commit message text. +` } function buildExamples(style) { if (style === "short") { return ` -Examples: -feat(cli): add --agent flag for provider selection -fix(config): resolve fallback model lookup on startup` + Examples: + feat(search): add fuzzy query support for command filtering + fix(auth): prevent token refresh loop on expired sessions + ` } return ` -Examples: -refactor(model): split selection workflow into helper modules + Examples (the hyphens are the part of the output, not an instruction): + refactor(cache): separate eviction policy from storage adapter + + - move policy decisions into a dedicated strategy helper + - keep cache reads/writes in a focused adapter layer + - reduce command handler branching for maintainability + + fix(importer): handle empty rows without aborting batch sync + + - skip malformed entries and continue ingesting valid records + - surface row-level warnings while preserving successful imports + ` +} + +function buildGuidanceBlock(refinementNote) { + const note = String(refinementNote || "").trim() -- move provider map creation into a dedicated options helper -- isolate api key prompting from model selection flow -- reduce model command complexity for easier maintenance + if (!note) { + return "" + } -fix(ai): normalize fallback response parsing + return ` + Guidance: + - Primary framing focus: ${note} + - Treat the framing focus as the main feature being described. + - Mention larger adjacent changes only if they directly support the framing focus. + - Do not describe infrastructure, setup, providers, or integrations unless the framing focus explicitly asks for them. -- handle array content payloads from chat completion providers -- preserve subject formatting when body is omitted` + - Use the framing focus as the primary interpretation context for the diff. + - Keep the commit framing narrow and aligned with the framing focus. + - Do not broaden the message into a different feature or repo-level summary. + ` } -function buildPrompt(diff, style = "adaptive", humanLikeCommit = true) { +function buildPrompt( + diff, + style = "adaptive", + humanLikeCommit = true, + refinementNote = "", +) { + const refinementMode = Boolean(String(refinementNote || "").trim()) + + if (refinementMode) { + return `You generate a single high-quality git commit message from a staged diff. + ${buildOutputContract(style)} + ${buildRefinementOutputContract(refinementNote, style)} + ${buildToneRules(humanLikeCommit)} + + Git diff: + ${diff} + + ${buildGuidanceBlock(refinementNote)} + ` + } + return `You generate a single high-quality git commit message from a staged diff. -${buildOutputContract(style)} -${buildCoreRules()} -${buildToneRules(humanLikeCommit)} -${buildExamples(style)} + ${buildOutputContract(style)} + ${buildCoreRules()} + ${buildDecisionProtocol()} + ${buildQualityGate()} + ${buildToneRules(humanLikeCommit)} + ${buildExamples(style)} -Git diff: -${diff}` + Git diff: + ${diff} + ` } export { buildPrompt } diff --git a/src/ai/providers.js b/src/ai/providers.js index e67f027..48230ec 100644 --- a/src/ai/providers.js +++ b/src/ai/providers.js @@ -5,6 +5,10 @@ async function generateWithGemini(agent, prompt) { const result = await ai.models.generateContent({ model: agent.model, contents: prompt, + config: { + temperature: 0, + topP: 1, + }, }) return result.text } @@ -18,12 +22,13 @@ async function generateWithOpenAICompatible(agent, prompt) { const body = { model: agent.model, - temperature: 0.2, + temperature: 0.1, + top_p: 1, messages: [ { role: "system", content: - "you are an expert engineer writing conventional commit messages from git diffs. Always return plain text commit message output only.", + "Return only the commit message text requested by the user prompt.", }, { role: "user", @@ -34,7 +39,7 @@ async function generateWithOpenAICompatible(agent, prompt) { const response = await fetch(endpoint, { method: "POST", - headers, + headers: agent.apiKey ? headers : {}, body: JSON.stringify(body), }) @@ -74,11 +79,93 @@ async function generateWithOpenAICompatible(agent, prompt) { throw new Error(`Unexpected ${agent.provider} response format.`) } +async function generateWithOllama(agent, prompt) { + const baseUrl = agent.baseUrl.replace(/\/$/, "") + const tagsResponse = await fetch(`${baseUrl}/api/tags`, { + method: "GET", + }).catch(() => null) + if (!tagsResponse || !tagsResponse.ok) { + throw new Error( + `Ollama server is not running or not reachable at ${baseUrl}. Start Ollama and try again.`, + ) + } + + const tagsPayload = await tagsResponse.json().catch(() => ({})) + const availableModels = Array.isArray(tagsPayload.models) + ? tagsPayload.models + : [] + const requestedModel = String(agent.model || "").trim() + const resolvedModel = availableModels.find((entry) => { + const name = String(entry?.name || entry?.model || "").trim() + return name === requestedModel + }) + + const selectedModel = resolvedModel + ? String( + resolvedModel.name || resolvedModel.model || requestedModel, + ).trim() + : String( + availableModels[0]?.name || availableModels[0]?.model || "", + ).trim() + + if (!selectedModel) { + throw new Error( + `Ollama is running at ${baseUrl}, but no installed models were returned from /api/tags. Pull a model with ollama pull and try again.`, + ) + } + + if (requestedModel && !resolvedModel) { + console.error( + `Ollama model not found: ${requestedModel}. Using ${selectedModel} instead.`, + ) + } + + console.log(`Using Ollama model: ${selectedModel}`) + + const endpoint = `${baseUrl}/api/generate` + const body = { + model: selectedModel, + prompt, + stream: false, + options: { + context: [], + temperature: 0.4, + top_p: 0.9, + }, + } + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }) + + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + const errorMessage = + payload.error || payload.message || response.statusText + throw new Error( + `${agent.provider} API error (${response.status}): ${errorMessage}`, + ) + } + + if (typeof payload.response === "string") { + return payload.response + } + + throw new Error(`Unexpected ${agent.provider} response format.`) +} + export async function generateFromModel(agent, model, prompt) { const modelAgent = { ...agent, model } const provider = String(modelAgent.provider || "gemini").toLowerCase() if (provider === "gemini") { return generateWithGemini(modelAgent, prompt) } + if (provider === "ollama") { + return generateWithOllama(modelAgent, prompt) + } return generateWithOpenAICompatible(modelAgent, prompt) } diff --git a/src/config/agents.js b/src/config/agents.js index ffe29dd..aa916ef 100644 --- a/src/config/agents.js +++ b/src/config/agents.js @@ -10,6 +10,11 @@ import { export function resolveAiAgents(config) { const { defaultModel, fallbackModel } = getGlobalModelPreferences(config) const requestedProvider = normalizeProvider(config.provider) + const requestedFallbackProvider = String( + getValue(config, ["FALLBACK_PROVIDER", "fallbackProvider"], ""), + ) + .trim() + .toLowerCase() const baseUrlOverride = getValue(config, ["BASE_URL", "baseUrl"], "") const primaryModel = @@ -23,7 +28,9 @@ export function resolveAiAgents(config) { getValue(config, ["apiKey"], "") const fallbackProvider = fallbackModel - ? getModelProvider(fallbackModel) || primaryProvider + ? requestedFallbackProvider || + getModelProvider(fallbackModel) || + primaryProvider : "" const rawFallbackApiKey = fallbackModel @@ -63,7 +70,9 @@ export function resolveAiAgents(config) { ] return { - agents: agents.filter((agent) => agent.apiKey), + agents: agents.filter( + (agent) => agent.provider === "ollama" || agent.apiKey, + ), defaultAgentName: "primary", providerDefaults: PROVIDER_DEFAULTS, } diff --git a/src/config/constants.js b/src/config/constants.js index 9346d84..10005ae 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -13,6 +13,10 @@ export const PROVIDER_DEFAULTS = { model: "deepseek-chat", baseUrl: "https://api.deepseek.com/v1", }, + ollama: { + model: "qwen2.5", + baseUrl: "http://localhost:11434", + }, } export const MODEL_CATALOG = [ @@ -22,16 +26,19 @@ export const MODEL_CATALOG = [ { provider: "groq", model: "llama-3.1-8b-instant" }, { provider: "deepseek", model: "deepseek-chat" }, { provider: "deepseek", model: "deepseek-reasoner" }, + { provider: "ollama", model: "qwen-2.5" }, ] export const PROVIDER_KEY_PREFIX = { gemini: "GEMINI", groq: "GROQ", deepseek: "DEEPSEEK", + ollama: "OLLAMA", } export const PROVIDER_API_URLS = { gemini: "https://aistudio.google.com/app/apikey", groq: "https://console.groq.com/keys", deepseek: "https://platform.deepseek.com/api_keys", + ollama: "" /* Ollama doesn't use API keys */, } diff --git a/src/config/providers.js b/src/config/providers.js index 6ea02e0..476d735 100644 --- a/src/config/providers.js +++ b/src/config/providers.js @@ -43,5 +43,6 @@ export function getAllProviderKeys(config) { gemini: getProviderApiKey(config, "gemini"), groq: getProviderApiKey(config, "groq"), deepseek: getProviderApiKey(config, "deepseek"), + ollama: "" /* Ollama doesn't use API keys */, } } diff --git a/src/main.js b/src/main.js index 55dc579..bea1c1b 100755 --- a/src/main.js +++ b/src/main.js @@ -66,11 +66,9 @@ export async function main() { `${fileStats.filter((f) => f.status === "D").length} deleted`, "Changeset Overview", ) - let diff = await getStagedDiff() - const diffLines = diff.split("\n") - if (diffLines.length > 200) { - diff = diffLines.slice(0, 200).join("\n") + "\n...diff truncated..." - } + const diff = await getStagedDiff() + + // console.log("Staged diff:\n", diff) if (!commitStyle) { commitStyle = await selectCommitStyle() @@ -82,7 +80,10 @@ export async function main() { const history = createMessageHistory() - const generateAndPush = async () => { + const generateAndPush = async ( + refinementNote = "", + styleOverride = commitStyle, + ) => { const selectedAgent = agents.find( (agent) => agent.name === selectedAgentName, ) @@ -96,11 +97,13 @@ export async function main() { `Generating commit message with ${selectedAgent.name} (${selectedAgent.provider}/${selectedAgent.model})...`, ) try { + // console.log("Generating for the diff:\n", diff) const message = await generateCommitMessage( selectedAgent, diff, - commitStyle, + styleOverride, humanLikeCommit, + refinementNote, ) spinner.stop() history.push({ message, agent: selectedAgent }) @@ -128,6 +131,11 @@ export async function main() { label: "Generate Again", hint: "Regenerate message", }, + { + value: "refine", + label: "Refine with Note", + hint: "Add optional guidance for the next generation", + }, ] if (history.hasPrev()) { options.push({ @@ -168,6 +176,22 @@ export async function main() { await generateAndPush() continue } + if (action === "refine") { + const refinementNote = await p.text({ + message: "Optional guidance for AI (leave empty to skip)", + placeholder: + "example: this one focuses on config changes too much, try to focus more on the added feature", + }) + if (p.isCancel(refinementNote)) { + p.cancel("Operation cancelled.") + process.exit(0) + } + await generateAndPush( + String(refinementNote || "").trim(), + commitStyle, + ) + continue + } if (action === "prev") { history.prev() continue diff --git a/src/model.js b/src/model.js index 6ab7143..b336344 100644 --- a/src/model.js +++ b/src/model.js @@ -117,6 +117,8 @@ export async function runModelCommand(subcommand = "") { await ensureProviderKey(fallbackProvider, storedKeys) const { configFile } = await upsertConfigEntries({ + provider: primaryProvider, + fallbackProvider, DEFAULT_MODEL: primaryModel, FALLBACK_MODEL: nextFallback, GEMINI_API_KEY: storedKeys.gemini || "", diff --git a/src/model/keys.js b/src/model/keys.js index 82ff6e6..07950e5 100644 --- a/src/model/keys.js +++ b/src/model/keys.js @@ -3,7 +3,11 @@ import { p } from "../prompts.js" import { KEY_SUPPORTED_PROVIDERS } from "./selection.js" export async function ensureProviderKey(provider, storedKeys) { - if (!provider || !KEY_SUPPORTED_PROVIDERS.includes(provider)) { + if ( + !provider || + !KEY_SUPPORTED_PROVIDERS.includes(provider) || + provider === "ollama" + ) { return } if (storedKeys[provider]) { diff --git a/src/model/selection.js b/src/model/selection.js index 79573b5..6025a08 100644 --- a/src/model/selection.js +++ b/src/model/selection.js @@ -2,7 +2,7 @@ import { getModelProvider } from "../utils.js" import { p } from "../prompts.js" import { toSelectOptions } from "./options.js" -export const KEY_SUPPORTED_PROVIDERS = ["gemini", "groq", "deepseek"] +export const KEY_SUPPORTED_PROVIDERS = ["gemini", "groq", "deepseek", "ollama"] function assertNotCancelled(value) { if (p.isCancel(value)) { diff --git a/src/setup.js b/src/setup.js index 1b36899..e8ee4d2 100644 --- a/src/setup.js +++ b/src/setup.js @@ -115,18 +115,18 @@ async function main() { const apiKeys = getAllProviderKeys(existingConfig) const primary = await selectModel("Select primary model") - apiKeys[primary.provider] = await askApiKey( - primary.provider, - apiKeys[primary.provider], - ) + apiKeys[primary.provider] = + primary.provider === "ollama" + ? "" + : await askApiKey(primary.provider, apiKeys[primary.provider]) const fallback = await selectModel("Select fallback model", true) const fallbackModel = fallback ? fallback.model : "" if (fallback) { - apiKeys[fallback.provider] = await askApiKey( - fallback.provider, - apiKeys[fallback.provider], - ) + apiKeys[fallback.provider] = + fallback.provider === "ollama" + ? "" + : await askApiKey(fallback.provider, apiKeys[fallback.provider]) } for (const provider of API_KEY_PROVIDERS) { @@ -144,6 +144,8 @@ async function main() { const config = `GEMINI_API_KEY=${apiKeys.gemini || ""} GROQ_API_KEY=${apiKeys.groq || ""} DEEPSEEK_API_KEY=${apiKeys.deepseek || ""} +provider=${primary.provider} +fallbackProvider=${fallback ? fallback.provider : ""} DEFAULT_MODEL=${primary.model} FALLBACK_MODEL=${fallbackModel} BASE_URL=