Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules
.env
Session.vim
.npmrc
PR
23 changes: 12 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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).
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions src/ai.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
221 changes: 170 additions & 51 deletions src/ai/prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Loading