neuro-commit 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -18
- package/bin/neuro-commit.js +79 -125
- package/package.json +4 -2
- package/src/ai.js +234 -0
- package/src/aiCommit.js +311 -0
- package/src/commit.js +51 -166
- package/src/config.js +68 -0
- package/src/git.js +98 -18
- package/src/settings.js +119 -0
- package/src/ui.js +164 -0
package/README.md
CHANGED
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
<a href="https://github.com/cr1ma/neuro-commit/blob/main/LICENSE"><img src="https://img.shields.io/github/license/cr1ma/neuro-commit" alt="license"></a>
|
|
9
9
|
<a href="https://github.com/cr1ma/neuro-commit/actions/workflows/publish.yml"><img src="https://github.com/cr1ma/neuro-commit/actions/workflows/publish.yml/badge.svg" alt="publish status"></a>
|
|
10
10
|
<a href="https://github.com/cr1ma/neuro-commit/issues"><img src="https://img.shields.io/github/issues/cr1ma/neuro-commit" alt="open issues"></a>
|
|
11
|
+
<a href="https://github.com/cr1ma/neuro-commit"><img src="https://img.shields.io/github/stars/cr1ma/neuro-commit" alt="stars"></a>
|
|
11
12
|
</p>
|
|
12
13
|
|
|
13
14
|
---
|
|
14
15
|
|
|
15
|
-
**NeuroCommit** is a
|
|
16
|
+
**NeuroCommit** is a CLI tool that analyzes your staged Git changes and generates commit messages — either automatically via OpenAI API or as a structured prompt you can paste into any LLM.
|
|
16
17
|
|
|
17
18
|
<p align="center">
|
|
18
19
|
<img src="docs/assets/neuro-commit-screenshot.png" alt="NeuroCommit CLI screenshot" width="700">
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
- [Features](#-features)
|
|
24
25
|
- [Quick Start](#-quick-start)
|
|
25
26
|
- [How It Works](#-how-it-works)
|
|
27
|
+
- [Configuration](#-configuration)
|
|
26
28
|
- [Development](#-development)
|
|
27
29
|
- [Contributing](#-contributing)
|
|
28
30
|
- [Security](#-security)
|
|
@@ -31,12 +33,14 @@
|
|
|
31
33
|
|
|
32
34
|
## ✨ Features
|
|
33
35
|
|
|
34
|
-
- **
|
|
35
|
-
- **
|
|
36
|
-
- **
|
|
37
|
-
- **
|
|
38
|
-
- **
|
|
39
|
-
- **
|
|
36
|
+
- **AI Commit mode** — generates and commits messages automatically using OpenAI API (`gpt-5-nano`) with Structured Outputs
|
|
37
|
+
- **Manual mode** — saves a prompt to `neuro-commit.md` for pasting into any LLM (ChatGPT, Claude, etc.)
|
|
38
|
+
- **Conventional Commits** — always uses `feat:`, `fix:`, `docs:`, `refactor:`, etc.
|
|
39
|
+
- **Smart lock file handling** — detects lock files and omits their noisy diffs
|
|
40
|
+
- **Minimal UI** — clean terminal interface, no visual clutter
|
|
41
|
+
- **Multi-language** — commit message body in English, Ukrainian, Russian, German, French, or Spanish
|
|
42
|
+
- **Configurable** — auto-commit, auto-push, commit history context, dev mode
|
|
43
|
+
- **Secure** — API key via environment variable only, no shell injection vectors
|
|
40
44
|
|
|
41
45
|
## 🚀 Quick Start
|
|
42
46
|
|
|
@@ -58,20 +62,52 @@ Then run:
|
|
|
58
62
|
neuro-commit
|
|
59
63
|
```
|
|
60
64
|
|
|
65
|
+
### Setting up OpenAI API Key
|
|
66
|
+
|
|
67
|
+
For AI Commit mode, set your API key:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Linux / macOS
|
|
71
|
+
export OPENAI_API_KEY="sk-..."
|
|
72
|
+
|
|
73
|
+
# Windows PowerShell
|
|
74
|
+
$env:OPENAI_API_KEY = "sk-..."
|
|
75
|
+
|
|
76
|
+
# Windows CMD
|
|
77
|
+
set OPENAI_API_KEY=sk-...
|
|
78
|
+
```
|
|
79
|
+
|
|
61
80
|
## 📖 How It Works
|
|
62
81
|
|
|
82
|
+
### AI Commit Mode
|
|
83
|
+
|
|
63
84
|
1. Stage your changes with `git add`
|
|
64
|
-
2. Run `neuro-commit`
|
|
65
|
-
3.
|
|
66
|
-
4. The tool
|
|
67
|
-
5.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
2. Run `neuro-commit` → select **AI Commit**
|
|
86
|
+
3. Review the file summary and confirm generation
|
|
87
|
+
4. The tool sends your diff to OpenAI API and generates a commit message
|
|
88
|
+
5. Choose: **Commit**, **Edit**, **Regenerate**, or **Cancel**
|
|
89
|
+
|
|
90
|
+
### Manual Mode
|
|
91
|
+
|
|
92
|
+
1. Stage your changes with `git add`
|
|
93
|
+
2. Run `neuro-commit` → select **Manual Mode**
|
|
94
|
+
3. A `neuro-commit.md` file is generated with the full prompt
|
|
95
|
+
4. Paste into your preferred LLM and get a commit message
|
|
96
|
+
|
|
97
|
+
> Both modes use the same prompt — the only difference is delivery method.
|
|
98
|
+
|
|
99
|
+
## ⚙️ Configuration
|
|
100
|
+
|
|
101
|
+
Settings are stored in `~/.neurocommit/config.json`. Access via the **Settings** menu.
|
|
102
|
+
|
|
103
|
+
| Setting | Default | Description |
|
|
104
|
+
| -------------- | ------- | -------------------------------------- |
|
|
105
|
+
| Language | `en` | Commit message body language |
|
|
106
|
+
| Max length | `72` | Title character limit |
|
|
107
|
+
| Auto-commit | `OFF` | Commit immediately after generation |
|
|
108
|
+
| Auto-push | `OFF` | Push after committing |
|
|
109
|
+
| Commit history | `5` | Recent commits included as AI context |
|
|
110
|
+
| Dev mode | `OFF` | Store API responses (OpenAI dashboard) |
|
|
75
111
|
|
|
76
112
|
## 🔧 Development
|
|
77
113
|
|
package/bin/neuro-commit.js
CHANGED
|
@@ -1,145 +1,99 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const readline = require("readline");
|
|
4
3
|
const { runCommitMode } = require("../src/commit");
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const CYAN = "\x1b[36m";
|
|
26
|
-
const HIDE_CURSOR = "\x1b[?25l";
|
|
27
|
-
const SHOW_CURSOR = "\x1b[?25h";
|
|
28
|
-
|
|
29
|
-
// Ensure cursor is restored if the process exits unexpectedly
|
|
30
|
-
process.on("exit", () => {
|
|
31
|
-
process.stdout.write(SHOW_CURSOR);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
process.on("SIGINT", () => {
|
|
4
|
+
const { runAiCommitMode } = require("../src/aiCommit");
|
|
5
|
+
const { runSettingsMenu } = require("../src/settings");
|
|
6
|
+
const { isAiAvailable } = require("../src/config");
|
|
7
|
+
const {
|
|
8
|
+
RESET,
|
|
9
|
+
BOLD,
|
|
10
|
+
DIM,
|
|
11
|
+
RED,
|
|
12
|
+
CYAN,
|
|
13
|
+
SHOW_CURSOR,
|
|
14
|
+
showSelectMenu,
|
|
15
|
+
} = require("../src/ui");
|
|
16
|
+
const pkg = require("../package.json");
|
|
17
|
+
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
|
|
20
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
21
|
+
console.log(`\n${BOLD}neuro-commit${RESET} v${pkg.version}\n`);
|
|
22
|
+
console.log(`Usage: neuro-commit`);
|
|
23
|
+
console.log(`Flags: -h, --help | -v, --version\n`);
|
|
35
24
|
process.exit(0);
|
|
36
|
-
}
|
|
25
|
+
}
|
|
37
26
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
process.
|
|
27
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
28
|
+
console.log(`v${pkg.version}`);
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
41
31
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
);
|
|
32
|
+
process.on("exit", () => process.stdout.write(SHOW_CURSOR));
|
|
33
|
+
process.on("SIGINT", () => process.exit(0));
|
|
45
34
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Non-blocking version check — runs once, prints hint if outdated.
|
|
37
|
+
*/
|
|
38
|
+
async function checkForUpdate() {
|
|
39
|
+
try {
|
|
40
|
+
const { default: latestVersion } = await import("latest-version");
|
|
41
|
+
const latest = await latestVersion(pkg.name);
|
|
42
|
+
if (latest && latest !== pkg.version) {
|
|
43
|
+
console.log(
|
|
44
|
+
`${DIM}Update available: ${pkg.version} → ${CYAN}${latest}${RESET}${DIM} (npm i -g ${pkg.name})${RESET}`,
|
|
55
45
|
);
|
|
56
46
|
}
|
|
47
|
+
} catch {
|
|
48
|
+
// silently ignore network errors
|
|
57
49
|
}
|
|
58
50
|
}
|
|
59
51
|
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
52
|
+
async function main() {
|
|
53
|
+
while (true) {
|
|
54
|
+
console.clear();
|
|
55
|
+
console.log(
|
|
56
|
+
`\n${BOLD}neuro-commit${RESET} ${DIM}v${pkg.version}${RESET}\n`,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// fire-and-forget update check (only first iteration matters visually)
|
|
60
|
+
checkForUpdate();
|
|
61
|
+
|
|
62
|
+
const choice = await showSelectMenu("Mode:", [
|
|
63
|
+
{ label: "AI Commit", description: "generate & commit" },
|
|
64
|
+
{ label: "Manual Mode", description: "save prompt to .md" },
|
|
65
|
+
{ label: "Settings" },
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
switch (choice) {
|
|
69
|
+
case 0: {
|
|
70
|
+
if (!isAiAvailable()) {
|
|
71
|
+
console.log(
|
|
72
|
+
`\n${RED}✖${RESET} Set ${CYAN}OPENAI_API_KEY${RESET} env variable first.`,
|
|
73
|
+
);
|
|
74
|
+
console.log(
|
|
75
|
+
`${DIM}PowerShell: $env:OPENAI_API_KEY = "sk-..."${RESET}`,
|
|
76
|
+
);
|
|
77
|
+
console.log(
|
|
78
|
+
`${DIM}Linux/macOS: export OPENAI_API_KEY="sk-..."${RESET}\n`,
|
|
79
|
+
);
|
|
80
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
console.clear();
|
|
84
|
+
await runAiCommitMode();
|
|
90
85
|
return;
|
|
91
86
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
renderMenu(selected);
|
|
87
|
+
case 1:
|
|
88
|
+
console.clear();
|
|
89
|
+
runCommitMode();
|
|
96
90
|
return;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
resolve(selected);
|
|
91
|
+
case 2:
|
|
92
|
+
await runSettingsMenu();
|
|
93
|
+
break;
|
|
94
|
+
default:
|
|
102
95
|
return;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function cleanup() {
|
|
107
|
-
process.stdin.removeListener("keypress", onKeyPress);
|
|
108
|
-
process.stdin.setRawMode(false);
|
|
109
|
-
process.stdin.pause();
|
|
110
|
-
process.stdout.write(SHOW_CURSOR);
|
|
111
96
|
}
|
|
112
|
-
|
|
113
|
-
process.stdin.on("keypress", onKeyPress);
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// --- Entry point ---
|
|
118
|
-
async function main() {
|
|
119
|
-
console.clear();
|
|
120
|
-
console.log(banner);
|
|
121
|
-
|
|
122
|
-
try {
|
|
123
|
-
const { default: updateNotifier } = await import("update-notifier");
|
|
124
|
-
const pkg = require("../package.json");
|
|
125
|
-
|
|
126
|
-
updateNotifier({
|
|
127
|
-
pkg,
|
|
128
|
-
updateCheckInterval: 1000 * 60 * 60 * 24,
|
|
129
|
-
}).notify({
|
|
130
|
-
defer: false,
|
|
131
|
-
isGlobal: true,
|
|
132
|
-
});
|
|
133
|
-
} catch {
|
|
134
|
-
// Ignore update check errors
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const choice = await showMenu();
|
|
138
|
-
|
|
139
|
-
switch (choice) {
|
|
140
|
-
case 0:
|
|
141
|
-
runCommitMode();
|
|
142
|
-
break;
|
|
143
97
|
}
|
|
144
98
|
}
|
|
145
99
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuro-commit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "neuro-commit CLI utility",
|
|
5
5
|
"author": "cr1ma.dev",
|
|
6
6
|
"bin": {
|
|
@@ -38,7 +38,9 @@
|
|
|
38
38
|
"globals": "^17.3.0"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
+
"latest-version": "^9.0.0",
|
|
42
|
+
"openai": "^6.22.0",
|
|
41
43
|
"tiktoken": "^1.0.22",
|
|
42
|
-
"
|
|
44
|
+
"zod": "^4.3.6"
|
|
43
45
|
}
|
|
44
46
|
}
|
package/src/ai.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
const OpenAI = require("openai");
|
|
2
|
+
const { z } = require("zod");
|
|
3
|
+
const { zodTextFormat } = require("openai/helpers/zod");
|
|
4
|
+
const { get_encoding } = require("tiktoken");
|
|
5
|
+
const { getApiKey, loadConfig } = require("./config");
|
|
6
|
+
const { isLockFile, statusLabel } = require("./git");
|
|
7
|
+
|
|
8
|
+
// Lazy-initialized tiktoken encoder (o200k_base for GPT-5 / GPT-4o family)
|
|
9
|
+
let _encoder = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Count tokens accurately using tiktoken (o200k_base).
|
|
13
|
+
*/
|
|
14
|
+
function countTokens(text) {
|
|
15
|
+
if (!_encoder) _encoder = get_encoding("o200k_base");
|
|
16
|
+
return _encoder.encode(text).length;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Pricing per 1M tokens — gpt-5-nano
|
|
20
|
+
const MODEL_PRICING = {
|
|
21
|
+
input: 0.05,
|
|
22
|
+
cachedInput: 0.005,
|
|
23
|
+
output: 0.4,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Structured output schema
|
|
27
|
+
const CommitMessage = z.object({
|
|
28
|
+
title: z
|
|
29
|
+
.string()
|
|
30
|
+
.describe("Commit title line (max ~72 chars, imperative mood, no period)"),
|
|
31
|
+
body: z
|
|
32
|
+
.array(z.string())
|
|
33
|
+
.describe("Bullet points describing key changes (without leading dash)"),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build a file summary string for the prompt.
|
|
38
|
+
*/
|
|
39
|
+
function buildFilesInfo(stagedFiles, numstat) {
|
|
40
|
+
const statMap = new Map();
|
|
41
|
+
for (const entry of numstat) {
|
|
42
|
+
statMap.set(entry.file, entry);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const lines = [];
|
|
46
|
+
for (const { status, file } of stagedFiles) {
|
|
47
|
+
const label = statusLabel(status).padEnd(10);
|
|
48
|
+
const s = statMap.get(file);
|
|
49
|
+
if (isLockFile(file)) {
|
|
50
|
+
lines.push(` ${label} ${file} | lock file (diff omitted)`);
|
|
51
|
+
} else {
|
|
52
|
+
const stat = s ? `| +${s.added} -${s.deleted}` : "";
|
|
53
|
+
lines.push(` ${label} ${file} ${stat}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build system prompt. Always uses Conventional Commits.
|
|
61
|
+
*/
|
|
62
|
+
function buildSystemPrompt(config, context = {}) {
|
|
63
|
+
const { language, maxLength } = config;
|
|
64
|
+
|
|
65
|
+
let langInstruction = "";
|
|
66
|
+
if (language && language !== "en") {
|
|
67
|
+
langInstruction = `\nWrite the commit message body in ${language} language. Keep the type prefix in English.`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let branchContext = "";
|
|
71
|
+
if (context.branch) {
|
|
72
|
+
branchContext = `\nCurrent branch: ${context.branch}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let historyContext = "";
|
|
76
|
+
if (context.recentCommits && context.recentCommits.length > 0) {
|
|
77
|
+
historyContext = `\nRecent commits for style reference:\n${context.recentCommits.map((c) => ` - ${c}`).join("\n")}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return `You are an expert at writing clear, concise git commit messages.
|
|
81
|
+
|
|
82
|
+
Rules:
|
|
83
|
+
1. Use Conventional Commits: feat:, fix:, docs:, style:, refactor:, test:, chore:, perf:, ci:, build:
|
|
84
|
+
2. If changes affect a specific scope, use parentheses: feat(auth): ...
|
|
85
|
+
3. Title max ${maxLength} chars, imperative mood, no period at end.
|
|
86
|
+
4. Body: concise bullet points for key changes.
|
|
87
|
+
5. Be specific — WHAT changed and WHY.
|
|
88
|
+
6. Omit file paths unless essential. Omit lock file changes.
|
|
89
|
+
${langInstruction}
|
|
90
|
+
${branchContext}
|
|
91
|
+
${historyContext}`.trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build user prompt with the diff content.
|
|
96
|
+
*/
|
|
97
|
+
function buildUserPrompt(filesInfo, diff) {
|
|
98
|
+
return `Staged changes:
|
|
99
|
+
|
|
100
|
+
Files:
|
|
101
|
+
${filesInfo}
|
|
102
|
+
|
|
103
|
+
Diff:
|
|
104
|
+
\`\`\`diff
|
|
105
|
+
${diff}
|
|
106
|
+
\`\`\`
|
|
107
|
+
|
|
108
|
+
Generate a commit message.`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Calculate cost from real API usage data.
|
|
113
|
+
*/
|
|
114
|
+
function calculateCost(usage) {
|
|
115
|
+
const inputTokens = usage.input_tokens || 0;
|
|
116
|
+
const cachedTokens = usage.input_tokens_details?.cached_tokens || 0;
|
|
117
|
+
const uncachedInput = inputTokens - cachedTokens;
|
|
118
|
+
const outputTokens = usage.output_tokens || 0;
|
|
119
|
+
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens || 0;
|
|
120
|
+
const totalTokens = usage.total_tokens || 0;
|
|
121
|
+
|
|
122
|
+
const cost =
|
|
123
|
+
(uncachedInput / 1_000_000) * MODEL_PRICING.input +
|
|
124
|
+
(cachedTokens / 1_000_000) * MODEL_PRICING.cachedInput +
|
|
125
|
+
(outputTokens / 1_000_000) * MODEL_PRICING.output;
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
cost,
|
|
129
|
+
inputTokens,
|
|
130
|
+
cachedTokens,
|
|
131
|
+
uncachedInput,
|
|
132
|
+
outputTokens,
|
|
133
|
+
reasoningTokens,
|
|
134
|
+
totalTokens,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Rough cost estimate before making a request.
|
|
140
|
+
*/
|
|
141
|
+
function estimateCost(estimatedInputTokens, estimatedOutputTokens = 200) {
|
|
142
|
+
return (
|
|
143
|
+
(estimatedInputTokens / 1_000_000) * MODEL_PRICING.input +
|
|
144
|
+
(estimatedOutputTokens / 1_000_000) * MODEL_PRICING.output
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Format structured response into conventional commit message string.
|
|
150
|
+
*/
|
|
151
|
+
function formatCommitMessage(parsed) {
|
|
152
|
+
const lines = [parsed.title];
|
|
153
|
+
if (parsed.body && parsed.body.length > 0) {
|
|
154
|
+
lines.push("");
|
|
155
|
+
for (const point of parsed.body) {
|
|
156
|
+
lines.push(`- ${point}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return lines.join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Extract parsed content from Structured Outputs response.
|
|
164
|
+
* Handles refusals gracefully.
|
|
165
|
+
*/
|
|
166
|
+
function extractParsedContent(response) {
|
|
167
|
+
for (const output of response.output) {
|
|
168
|
+
if (output.type !== "message") continue;
|
|
169
|
+
for (const item of output.content) {
|
|
170
|
+
if (item.type === "refusal") {
|
|
171
|
+
throw new Error(`Model refused: ${item.refusal}`);
|
|
172
|
+
}
|
|
173
|
+
if (item.parsed) {
|
|
174
|
+
return item.parsed;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
throw new Error("Could not parse structured response.");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Generate a commit message via OpenAI Responses API with Structured Outputs.
|
|
183
|
+
* Returns { message, usage }.
|
|
184
|
+
*/
|
|
185
|
+
async function generateCommitMessage(
|
|
186
|
+
diff,
|
|
187
|
+
filesInfo,
|
|
188
|
+
context = {},
|
|
189
|
+
extraInstruction = "",
|
|
190
|
+
) {
|
|
191
|
+
const apiKey = getApiKey();
|
|
192
|
+
if (!apiKey) {
|
|
193
|
+
throw new Error("OPENAI_API_KEY not set.");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const config = loadConfig();
|
|
197
|
+
const client = new OpenAI({ apiKey });
|
|
198
|
+
|
|
199
|
+
const systemPrompt = buildSystemPrompt(config, context) + extraInstruction;
|
|
200
|
+
const userPrompt = buildUserPrompt(filesInfo, diff);
|
|
201
|
+
|
|
202
|
+
const params = {
|
|
203
|
+
model: config.model,
|
|
204
|
+
instructions: systemPrompt,
|
|
205
|
+
input: userPrompt,
|
|
206
|
+
reasoning: { effort: "low" },
|
|
207
|
+
text: {
|
|
208
|
+
format: zodTextFormat(CommitMessage, "commit_message"),
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
if (config.devMode) {
|
|
213
|
+
params.store = true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const response = await client.responses.parse(params);
|
|
217
|
+
const parsed = extractParsedContent(response);
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
message: formatCommitMessage(parsed),
|
|
221
|
+
usage: response.usage || null,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = {
|
|
226
|
+
buildFilesInfo,
|
|
227
|
+
buildSystemPrompt,
|
|
228
|
+
buildUserPrompt,
|
|
229
|
+
generateCommitMessage,
|
|
230
|
+
calculateCost,
|
|
231
|
+
estimateCost,
|
|
232
|
+
countTokens,
|
|
233
|
+
MODEL_PRICING,
|
|
234
|
+
};
|