gent-cli 27.0.0 → 29.0.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 +9 -5
- package/package.json +3 -3
- package/src/commands/ai.js +44 -4
- package/src/commands/canonical.js +3 -2
- package/src/commands/doctor.js +3 -3
- package/src/commands/resolve.js +41 -37
- package/src/index.js +3 -2
- package/src/utils/ai-service.js +95 -39
- package/src/utils/local-ai-config.js +51 -0
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Beyond a faithful git-like workflow, Gent adds:
|
|
|
24
24
|
- **`gent undo` / `gent redo`** — a one-command safety net over an operation journal (friendlier than `git reflog`).
|
|
25
25
|
- **`gent resolve`** — an interactive conflict resolver (ours / theirs / both / edit / AI).
|
|
26
26
|
- **`gent summary`** — a repository health dashboard, plus **`gent log --graph`**.
|
|
27
|
-
- **
|
|
27
|
+
- **Direct local AI** (`gent chat`, `gent review`, `gent merge --ai`, `gent resolve --ai`) — low-latency OpenRouter calls from the CLI with task-specific prompts. Configure once with `gent ai configure`.
|
|
28
28
|
- **Genti, your terminal mascot** — a mint one-eyed sky-jelly that *acts out* your workflow: it floats a file crate to the cloud on `gent push`, carries one home on `gent pull`, and reconciles two branches on `gent merge`. It plays once (in place, no scrollback spam) after a successful command. Meet it directly with `gent pet` (add `--loop` to keep it running; try `gent pet push|pull|merge|auth`). Set `GENT_NO_PET=1` (or run in CI / a non-interactive shell) to turn the celebrations off.
|
|
29
29
|
|
|
30
30
|
See [docs/COMMANDS.md](docs/COMMANDS.md) for the full reference and
|
|
@@ -500,14 +500,18 @@ gent explain --staged # explain currently staged changes
|
|
|
500
500
|
|
|
501
501
|
### Optional AI features
|
|
502
502
|
|
|
503
|
-
AI calls run directly from the CLI
|
|
504
|
-
|
|
505
|
-
prompts keep review, merge resolution, chat,
|
|
503
|
+
AI calls run directly from the CLI. Run `gent ai configure` once to enter the
|
|
504
|
+
OpenRouter key through a masked prompt; Gent stores it locally with owner-only
|
|
505
|
+
permissions. Fast task-specific prompts keep review, merge resolution, chat,
|
|
506
|
+
and summaries concise.
|
|
506
507
|
|
|
507
508
|
```bash
|
|
509
|
+
gent ai configure # configure OpenRouter once on this computer
|
|
510
|
+
gent ai configure <your-key> # same, non-interactive (scripts, SSH)
|
|
511
|
+
gent ai configure <your-key> --model vendor/model # pin a different model
|
|
508
512
|
gent commit --ai # suggest a commit message from the staged diff
|
|
509
513
|
gent explain # narrate a diff instead of just printing it
|
|
510
|
-
gent resolve #
|
|
514
|
+
gent resolve # ours/theirs/both/AI/edit/skip per conflict hunk
|
|
511
515
|
```
|
|
512
516
|
|
|
513
517
|
### Tags
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gent-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "29.0.0",
|
|
4
4
|
"description": "A modern, Git-like version control CLI with cloud sync, AI-powered superpowers (ask/review/docs/changelog), and zero-friction setup (gent setup/doctor/config).",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node src/index.js",
|
|
11
|
-
"test": "node --check src/index.js && node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js tests/repos.test.js tests/ai-service.test.js tests/auth-storage.test.js tests/resolve-options.test.js && node tests/offline-e2e.js && node tests/ai-merge.e2e.js && node --test tests/git-compat/engine.test.js tests/git-compat/cli.test.js tests/git-compat/transport.test.js tests/git-compat/migrate.test.js",
|
|
12
|
-
"test:unit": "node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js tests/repos.test.js tests/ai-service.test.js tests/auth-storage.test.js tests/resolve-options.test.js",
|
|
11
|
+
"test": "node --check src/index.js && node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js tests/repos.test.js tests/ai-service.test.js tests/auth-storage.test.js tests/local-ai-config.test.js tests/resolve-options.test.js && node tests/offline-e2e.js && node tests/ai-merge.e2e.js && node --test tests/git-compat/engine.test.js tests/git-compat/cli.test.js tests/git-compat/transport.test.js tests/git-compat/migrate.test.js",
|
|
12
|
+
"test:unit": "node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js tests/repos.test.js tests/ai-service.test.js tests/auth-storage.test.js tests/local-ai-config.test.js tests/resolve-options.test.js",
|
|
13
13
|
"test:e2e": "node tests/offline-e2e.js",
|
|
14
14
|
"test:remote:e2e": "node tests/remote-e2e.js",
|
|
15
15
|
"demo": "bash demo.sh",
|
package/src/commands/ai.js
CHANGED
|
@@ -1,28 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AI Command - Manage and verify AI integration.
|
|
3
3
|
*
|
|
4
|
-
* gent ai
|
|
4
|
+
* gent ai configure → securely save your own key for this computer
|
|
5
|
+
* gent ai configure <key> → same, without the interactive prompt
|
|
6
|
+
* gent ai status → show local AI status
|
|
5
7
|
* gent ai test → make a tiny live request to confirm it works
|
|
6
8
|
* gent ai models → show provider management information
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
const chalk = require('chalk');
|
|
12
|
+
const inquirer = require('inquirer');
|
|
10
13
|
const ora = require('ora');
|
|
11
14
|
const ai = require('../utils/ai-service');
|
|
15
|
+
const localAiConfig = require('../utils/local-ai-config');
|
|
12
16
|
|
|
13
|
-
async function aiCommand(subcommand) {
|
|
17
|
+
async function aiCommand(subcommand, key, options = {}) {
|
|
14
18
|
const sub = (subcommand || 'status').toLowerCase();
|
|
15
19
|
switch (sub) {
|
|
20
|
+
case 'configure': return configure(key, options);
|
|
16
21
|
case 'status': return status();
|
|
17
22
|
case 'test': return test();
|
|
18
23
|
case 'models': return models();
|
|
19
24
|
default:
|
|
20
25
|
console.error(chalk.red(`Unknown subcommand '${sub}'`));
|
|
21
|
-
console.log(chalk.gray('Usage: gent ai <status|test|models>'));
|
|
26
|
+
console.log(chalk.gray('Usage: gent ai <configure|status|test|models>'));
|
|
22
27
|
process.exit(1);
|
|
23
28
|
}
|
|
24
29
|
}
|
|
25
30
|
|
|
31
|
+
async function configure(key, options = {}) {
|
|
32
|
+
// A key passed as an argument keeps `gent ai configure` usable in scripts,
|
|
33
|
+
// over SSH, and anywhere stdin is not a terminal.
|
|
34
|
+
let apiKey = typeof key === 'string' ? key.trim() : '';
|
|
35
|
+
|
|
36
|
+
if (!apiKey) {
|
|
37
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
38
|
+
console.error(chalk.red('No terminal available for the masked prompt.'));
|
|
39
|
+
console.log(chalk.gray(' Pass the key directly: gent ai configure <your-openrouter-key>'));
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
({ apiKey } = await inquirer.prompt([{
|
|
44
|
+
type: 'password',
|
|
45
|
+
name: 'apiKey',
|
|
46
|
+
message: 'OpenRouter API key:',
|
|
47
|
+
mask: '*',
|
|
48
|
+
validate: value => localAiConfig.validateApiKey(value) || 'Enter a valid OpenRouter key beginning with sk-or-v1-.',
|
|
49
|
+
}]));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!localAiConfig.validateApiKey(apiKey)) {
|
|
53
|
+
console.error(chalk.red('That is not a valid OpenRouter key. Keys begin with sk-or-v1-.'));
|
|
54
|
+
console.log(chalk.gray(' Create one at https://openrouter.ai/keys'));
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const savedPath = await localAiConfig.saveApiKey(apiKey, { model: options.model });
|
|
60
|
+
console.log(chalk.green('✓ Gent AI configured for this computer with your own key.'));
|
|
61
|
+
console.log(chalk.gray(` Stored locally in ${savedPath} with owner-only permissions.`));
|
|
62
|
+
console.log(chalk.gray(` Model: ${await ai.resolveModel()}`));
|
|
63
|
+
console.log(chalk.gray(' Run `gent ai test` to verify it.'));
|
|
64
|
+
}
|
|
65
|
+
|
|
26
66
|
async function status() {
|
|
27
67
|
const { value: available, source } = await ai.resolveKey();
|
|
28
68
|
|
|
@@ -49,7 +89,7 @@ async function test() {
|
|
|
49
89
|
try {
|
|
50
90
|
const reply = await ai.complete({
|
|
51
91
|
prompt: 'Reply with the single word: pong',
|
|
52
|
-
maxTokens:
|
|
92
|
+
maxTokens: 32,
|
|
53
93
|
});
|
|
54
94
|
spinner.succeed(chalk.green(`✓ Reachable. Reply: "${reply}"`));
|
|
55
95
|
} catch (err) {
|
|
@@ -216,7 +216,7 @@ const handlers = {
|
|
|
216
216
|
continue;
|
|
217
217
|
}
|
|
218
218
|
try {
|
|
219
|
-
const
|
|
219
|
+
const resolution = await ai.resolveConflictHunk({
|
|
220
220
|
base: sides.base?.toString('utf8') || '',
|
|
221
221
|
ours: sides.ours?.toString('utf8') || '',
|
|
222
222
|
theirs: sides.theirs?.toString('utf8') || '',
|
|
@@ -226,10 +226,11 @@ const handlers = {
|
|
|
226
226
|
await worktree.assertNoSymlinkParent(repo, name);
|
|
227
227
|
const absolute = path.join(repo.worktree, name);
|
|
228
228
|
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
|
229
|
-
await fs.writeFile(absolute,
|
|
229
|
+
await fs.writeFile(absolute, resolution.merged, 'utf8');
|
|
230
230
|
await merge.markResolved(repo, name);
|
|
231
231
|
resolved++;
|
|
232
232
|
console.log(`Resolved and staged ${name}`);
|
|
233
|
+
console.log(` AI: ${resolution.summary}`);
|
|
233
234
|
} catch (error) {
|
|
234
235
|
console.log(`AI did not resolve ${name}: ${error.message}`);
|
|
235
236
|
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -137,7 +137,7 @@ async function checkAiKey(probe) {
|
|
|
137
137
|
name: 'AI service',
|
|
138
138
|
status: 'warn',
|
|
139
139
|
detail: 'unavailable in this CLI installation',
|
|
140
|
-
hint: '
|
|
140
|
+
hint: 'Run `gent ai configure` once on this computer.',
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
143
|
|
|
@@ -154,14 +154,14 @@ async function checkAiKey(probe) {
|
|
|
154
154
|
return {
|
|
155
155
|
name: 'AI service',
|
|
156
156
|
status: 'pass',
|
|
157
|
-
detail: 'verified —
|
|
157
|
+
detail: 'verified — OpenRouter responded',
|
|
158
158
|
};
|
|
159
159
|
} catch (err) {
|
|
160
160
|
return {
|
|
161
161
|
name: 'AI service',
|
|
162
162
|
status: 'fail',
|
|
163
163
|
detail: err.message,
|
|
164
|
-
hint: '
|
|
164
|
+
hint: 'Run `gent ai configure` to replace the key, or check credits at https://openrouter.ai/credits.',
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
167
|
}
|
package/src/commands/resolve.js
CHANGED
|
@@ -33,13 +33,6 @@ const journal = require('../utils/journal');
|
|
|
33
33
|
const ai = require('../utils/ai-service');
|
|
34
34
|
const reviewCommand = require('./review');
|
|
35
35
|
|
|
36
|
-
function resolutionModes() {
|
|
37
|
-
return [
|
|
38
|
-
{ name: 'Resolve with AI (fast) — resolve, commit, then review', value: 'ai' },
|
|
39
|
-
{ name: 'Resolve manually — choose each conflict', value: 'manual' },
|
|
40
|
-
];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
36
|
async function resolve(options = {}) {
|
|
44
37
|
try {
|
|
45
38
|
await ai.prime();
|
|
@@ -72,16 +65,6 @@ async function resolve(options = {}) {
|
|
|
72
65
|
return;
|
|
73
66
|
}
|
|
74
67
|
|
|
75
|
-
if (!options.ai && process.stdin.isTTY && process.stdout.isTTY) {
|
|
76
|
-
const { mode } = await inquirer.prompt([{
|
|
77
|
-
type: 'list',
|
|
78
|
-
name: 'mode',
|
|
79
|
-
message: 'How should Gent resolve this merge?',
|
|
80
|
-
choices: resolutionModes(),
|
|
81
|
-
}]);
|
|
82
|
-
options.ai = mode === 'ai';
|
|
83
|
-
}
|
|
84
|
-
|
|
85
68
|
console.log(chalk.bold.cyan(`\nResolving merge of '${mergeState.sourceBranch}' — ${markerFiles.length} file(s)\n`));
|
|
86
69
|
|
|
87
70
|
// Working copy of merged tree entries (we patch hashes as files resolve).
|
|
@@ -110,6 +93,7 @@ async function resolve(options = {}) {
|
|
|
110
93
|
let idx = 0;
|
|
111
94
|
let aborted = false;
|
|
112
95
|
const out = [];
|
|
96
|
+
const aiSummaries = [];
|
|
113
97
|
|
|
114
98
|
for (const seg of segments) {
|
|
115
99
|
if (seg.type === 'text') {
|
|
@@ -117,7 +101,7 @@ async function resolve(options = {}) {
|
|
|
117
101
|
continue;
|
|
118
102
|
}
|
|
119
103
|
idx++;
|
|
120
|
-
const resolvedLines = await resolveHunk(seg, file, idx, conflictCount, options);
|
|
104
|
+
const resolvedLines = await resolveHunk(seg, file, idx, conflictCount, options, aiSummaries);
|
|
121
105
|
if (resolvedLines === null) { aborted = true; break; }
|
|
122
106
|
out.push(...resolvedLines);
|
|
123
107
|
}
|
|
@@ -137,6 +121,9 @@ async function resolve(options = {}) {
|
|
|
137
121
|
} else {
|
|
138
122
|
await stageResolved(gentPath, staging, entriesByName, file, resolvedContent);
|
|
139
123
|
console.log(chalk.green(` ✓ resolved ${file}`));
|
|
124
|
+
if (aiSummaries.length) {
|
|
125
|
+
console.log(chalk.gray(` AI: ${summarizeFileChanges(aiSummaries)}`));
|
|
126
|
+
}
|
|
140
127
|
}
|
|
141
128
|
}
|
|
142
129
|
|
|
@@ -181,24 +168,17 @@ async function resolve(options = {}) {
|
|
|
181
168
|
* Prompt for one conflict hunk. Returns the chosen lines, or null to abort
|
|
182
169
|
* (leave the rest of the file as-is with markers).
|
|
183
170
|
*/
|
|
184
|
-
async function resolveHunk(seg, file, idx, total, options = {}) {
|
|
171
|
+
async function resolveHunk(seg, file, idx, total, options = {}, aiSummaries = []) {
|
|
185
172
|
console.log(chalk.gray(` Conflict ${idx}/${total}:`));
|
|
186
173
|
console.log(chalk.green(' <<< ours'));
|
|
187
174
|
seg.ours.forEach(l => console.log(chalk.green(` ${l}`)));
|
|
188
175
|
console.log(chalk.red(' >>> theirs'));
|
|
189
176
|
seg.theirs.forEach(l => console.log(chalk.red(` ${l}`)));
|
|
190
177
|
|
|
191
|
-
const choices =
|
|
192
|
-
{ name: 'Keep ours', value: 'ours' },
|
|
193
|
-
{ name: 'Keep theirs', value: 'theirs' },
|
|
194
|
-
{ name: 'Keep both (ours then theirs)', value: 'both' },
|
|
195
|
-
{ name: 'Edit manually', value: 'edit' }
|
|
196
|
-
];
|
|
197
|
-
choices.splice(3, 0, { name: `Resolve with AI (${ai.getModel()})`, value: 'ai' });
|
|
198
|
-
choices.push({ name: 'Skip the rest of this file', value: 'skip' });
|
|
178
|
+
const choices = resolutionChoices();
|
|
199
179
|
|
|
200
180
|
if (options.ai) {
|
|
201
|
-
const suggestion = await askAiForHunk(seg, file, true);
|
|
181
|
+
const suggestion = await askAiForHunk(seg, file, true, aiSummaries);
|
|
202
182
|
if (suggestion !== null) return suggestion;
|
|
203
183
|
return null;
|
|
204
184
|
}
|
|
@@ -225,40 +205,64 @@ async function resolveHunk(seg, file, idx, total, options = {}) {
|
|
|
225
205
|
return text.replace(/\n$/, '').split('\n');
|
|
226
206
|
}
|
|
227
207
|
case 'ai': {
|
|
228
|
-
const suggestion = await askAiForHunk(seg, file);
|
|
208
|
+
const suggestion = await askAiForHunk(seg, file, false, aiSummaries);
|
|
229
209
|
if (suggestion !== null) return suggestion;
|
|
230
|
-
return resolveHunk(seg, file, idx, total, { ai: false });
|
|
210
|
+
return resolveHunk(seg, file, idx, total, { ai: false }, aiSummaries);
|
|
231
211
|
}
|
|
232
212
|
default: return seg.ours;
|
|
233
213
|
}
|
|
234
214
|
}
|
|
235
215
|
|
|
236
|
-
|
|
216
|
+
function resolutionChoices() {
|
|
217
|
+
return [
|
|
218
|
+
{ name: 'Keep ours', value: 'ours' },
|
|
219
|
+
{ name: 'Keep theirs', value: 'theirs' },
|
|
220
|
+
{ name: 'Keep both (ours then theirs)', value: 'both' },
|
|
221
|
+
{ name: `Resolve with AI (${ai.getModel()})`, value: 'ai' },
|
|
222
|
+
{ name: 'Edit manually', value: 'edit' },
|
|
223
|
+
{ name: 'Skip the rest of this file', value: 'skip' },
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function askAiForHunk(seg, file, autoAccept = false, aiSummaries = []) {
|
|
237
228
|
try {
|
|
238
|
-
const
|
|
229
|
+
const resolution = await ai.resolveConflictHunk({
|
|
239
230
|
ours: seg.ours.join('\n'),
|
|
240
231
|
theirs: seg.theirs.join('\n'),
|
|
241
232
|
fileName: file
|
|
242
233
|
});
|
|
243
234
|
if (autoAccept) {
|
|
244
|
-
|
|
245
|
-
return
|
|
235
|
+
aiSummaries.push(resolution.summary);
|
|
236
|
+
return resolution.merged.split('\n');
|
|
246
237
|
}
|
|
247
238
|
console.log(chalk.cyan(' AI suggestion (review before accepting):'));
|
|
248
|
-
|
|
239
|
+
resolution.merged.split('\n').forEach(line => console.log(chalk.cyan(` ${line}`)));
|
|
249
240
|
const { accept } = await inquirer.prompt([{
|
|
250
241
|
type: 'confirm',
|
|
251
242
|
name: 'accept',
|
|
252
243
|
message: 'Use this AI suggestion?',
|
|
253
244
|
default: false,
|
|
254
245
|
}]);
|
|
255
|
-
|
|
246
|
+
if (!accept) return null;
|
|
247
|
+
aiSummaries.push(resolution.summary);
|
|
248
|
+
return resolution.merged.split('\n');
|
|
256
249
|
} catch (error) {
|
|
257
250
|
console.log(chalk.yellow(` AI failed (${error.message}); no file was changed.`));
|
|
258
251
|
return null;
|
|
259
252
|
}
|
|
260
253
|
}
|
|
261
254
|
|
|
255
|
+
function summarizeFileChanges(summaries) {
|
|
256
|
+
const summary = [...new Set(summaries)].join('; ');
|
|
257
|
+
const words = summary.split(/\s+/);
|
|
258
|
+
const wordLimited = words.length <= 32
|
|
259
|
+
? summary
|
|
260
|
+
: `${words.slice(0, 32).join(' ')}...`;
|
|
261
|
+
return wordLimited.length <= 220
|
|
262
|
+
? wordLimited
|
|
263
|
+
: `${wordLimited.slice(0, 217).trimEnd()}...`;
|
|
264
|
+
}
|
|
265
|
+
|
|
262
266
|
/** Store the resolved file as a blob, patch the tree entry, and stage it. */
|
|
263
267
|
async function stageResolved(gentPath, staging, entriesByName, file, content) {
|
|
264
268
|
const hash = await storeBlob(gentPath, content);
|
|
@@ -326,4 +330,4 @@ async function finalizeMerge(gentPath, staging, mergeState, entriesByName) {
|
|
|
326
330
|
}
|
|
327
331
|
|
|
328
332
|
module.exports = resolve;
|
|
329
|
-
module.exports.
|
|
333
|
+
module.exports.resolutionChoices = resolutionChoices;
|
package/src/index.js
CHANGED
|
@@ -369,8 +369,9 @@ program
|
|
|
369
369
|
.action(doctorCommand);
|
|
370
370
|
|
|
371
371
|
program
|
|
372
|
-
.command('ai [subcommand]')
|
|
373
|
-
.description('
|
|
372
|
+
.command('ai [subcommand] [key]')
|
|
373
|
+
.description('Configure or inspect local AI (configure|status|test|models)')
|
|
374
|
+
.option('--model <id>', 'Pin an OpenRouter model id for this computer')
|
|
374
375
|
.action(aiCommand);
|
|
375
376
|
|
|
376
377
|
// ─── Platform-special (AI-powered) ──────────────────────
|
package/src/utils/ai-service.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Direct, low-latency OpenRouter client for Gent's local AI commands. */
|
|
2
2
|
|
|
3
3
|
const axios = require('axios');
|
|
4
|
-
const apiClient = require('./api-client');
|
|
5
|
-
const authStorage = require('./auth-storage');
|
|
6
4
|
|
|
7
|
-
const API_URL = 'https://
|
|
8
|
-
const DEFAULT_MODEL = '
|
|
9
|
-
|
|
5
|
+
const API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
6
|
+
const DEFAULT_MODEL = 'xiaomi/mimo-v2.5:nitro';
|
|
7
|
+
// The default model reasons before answering, and those tokens come out of the
|
|
8
|
+
// same budget as the reply. Too small a budget returns content: null with
|
|
9
|
+
// finish_reason "length", so keep a floor and add headroom on every request.
|
|
10
|
+
const MIN_OUTPUT_TOKENS = 256;
|
|
11
|
+
const REASONING_HEADROOM = 512;
|
|
10
12
|
|
|
11
13
|
const PROMPTS = Object.freeze({
|
|
12
14
|
chat: 'Answer as a concise senior engineer. Use the repository context. Give the direct answer first.',
|
|
13
15
|
review: 'Review fast. Return only concrete correctness, security, or regression risks, then brief fixes. If none, say "No blocking issues."',
|
|
14
|
-
merge: 'Resolve this merge conflict fast. Preserve both intended behaviors. Return only
|
|
16
|
+
merge: 'Resolve this merge conflict fast. Preserve both intended behaviors. Return only valid JSON with this shape: {"merged":"final merged text","summary":"one sentence, at most 18 words, saying what was kept or combined"}. Do not use markdown fences.',
|
|
15
17
|
commit: 'Write one concise conventional commit message. Return only the message.',
|
|
16
18
|
explain: 'Explain this change briefly and concretely. Return short bullets only.',
|
|
17
19
|
docs: 'Write concise, accurate repository documentation from only the supplied context.',
|
|
@@ -20,7 +22,9 @@ const PROMPTS = Object.freeze({
|
|
|
20
22
|
});
|
|
21
23
|
|
|
22
24
|
function getApiKey() {
|
|
23
|
-
|
|
25
|
+
// OPENAI_API_KEY stays readable so installs configured before the
|
|
26
|
+
// OpenRouter switch keep working until they reconfigure.
|
|
27
|
+
return process.env.OPENROUTER_API_KEY || process.env.OPENAI_API_KEY || null;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
function getModel() {
|
|
@@ -33,9 +37,7 @@ function getApiUrl() {
|
|
|
33
37
|
|
|
34
38
|
async function resolveKey() {
|
|
35
39
|
const value = getApiKey();
|
|
36
|
-
|
|
37
|
-
managedEnabled = Boolean(await authStorage.getAccessToken());
|
|
38
|
-
return { value: managedEnabled ? 'managed' : null, source: managedEnabled ? 'managed Gent service' : 'unset' };
|
|
40
|
+
return { value, source: value ? 'local CLI configuration (OpenRouter)' : 'unset' };
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
async function resolveModel() {
|
|
@@ -47,15 +49,27 @@ async function prime() {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
function isEnabled() {
|
|
50
|
-
return Boolean(getApiKey())
|
|
52
|
+
return Boolean(getApiKey());
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
function disabledHint() {
|
|
54
|
-
return 'Gent AI is unavailable.
|
|
56
|
+
return 'Gent AI is unavailable. Run `gent ai configure` once on this computer.';
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
function extractText(payload) {
|
|
58
60
|
const chunks = [];
|
|
61
|
+
// OpenRouter / chat-completions shape.
|
|
62
|
+
for (const choice of payload?.choices || []) {
|
|
63
|
+
const content = choice?.message?.content;
|
|
64
|
+
if (typeof content === 'string') {
|
|
65
|
+
chunks.push(content);
|
|
66
|
+
} else if (Array.isArray(content)) {
|
|
67
|
+
for (const part of content) {
|
|
68
|
+
if (typeof part?.text === 'string') chunks.push(part.text);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Responses-API shape, still accepted so a custom GENT_AI_API_URL works.
|
|
59
73
|
for (const item of payload?.output || []) {
|
|
60
74
|
if (item.type !== 'message') continue;
|
|
61
75
|
for (const content of item.content || []) {
|
|
@@ -65,38 +79,40 @@ function extractText(payload) {
|
|
|
65
79
|
return chunks.join('').trim();
|
|
66
80
|
}
|
|
67
81
|
|
|
82
|
+
function truncatedByBudget(payload) {
|
|
83
|
+
return (payload?.choices || []).some(choice => choice?.finish_reason === 'length');
|
|
84
|
+
}
|
|
85
|
+
|
|
68
86
|
async function complete({ prompt, system, profile = 'chat', maxTokens = 1024 }) {
|
|
69
87
|
const apiKey = getApiKey();
|
|
70
|
-
if (!apiKey
|
|
71
|
-
if (!apiKey && !managedEnabled) throw new Error(disabledHint());
|
|
88
|
+
if (!apiKey) throw new Error(disabledHint());
|
|
72
89
|
const instructions = [PROMPTS[profile], system].filter(Boolean).join('\n\n');
|
|
90
|
+
const budget = Math.max(MIN_OUTPUT_TOKENS, maxTokens) + REASONING_HEADROOM;
|
|
73
91
|
try {
|
|
74
|
-
if (!apiKey) {
|
|
75
|
-
const response = await apiClient.post('/api/ai/complete/', {
|
|
76
|
-
profile,
|
|
77
|
-
prompt,
|
|
78
|
-
system,
|
|
79
|
-
max_tokens: maxTokens,
|
|
80
|
-
});
|
|
81
|
-
const text = response?.output_text?.trim();
|
|
82
|
-
if (!text) throw new Error('Gent AI returned an empty response');
|
|
83
|
-
return text;
|
|
84
|
-
}
|
|
85
92
|
const response = await axios.post(getApiUrl(), {
|
|
86
93
|
model: getModel(),
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
94
|
+
messages: [
|
|
95
|
+
...(instructions ? [{ role: 'system', content: instructions }] : []),
|
|
96
|
+
{ role: 'user', content: prompt },
|
|
97
|
+
],
|
|
98
|
+
max_tokens: budget,
|
|
99
|
+
// Keep reasoning models brief; ignored by models without reasoning.
|
|
100
|
+
reasoning: { effort: 'low' },
|
|
91
101
|
}, {
|
|
92
102
|
headers: {
|
|
93
103
|
Authorization: `Bearer ${apiKey}`,
|
|
94
104
|
'Content-Type': 'application/json',
|
|
105
|
+
'HTTP-Referer': 'https://github.com/gent-cli',
|
|
106
|
+
'X-Title': 'Gent CLI',
|
|
95
107
|
},
|
|
96
|
-
timeout:
|
|
108
|
+
timeout: 60000,
|
|
97
109
|
});
|
|
98
110
|
const text = extractText(response.data);
|
|
99
|
-
if (!text)
|
|
111
|
+
if (!text) {
|
|
112
|
+
throw new Error(truncatedByBudget(response.data)
|
|
113
|
+
? `Model "${getModel()}" used the whole ${budget}-token budget before replying. Raise it or set GENT_AI_MODEL to a lighter model.`
|
|
114
|
+
: `Model "${getModel()}" returned an empty response.`);
|
|
115
|
+
}
|
|
100
116
|
return text;
|
|
101
117
|
} catch (error) {
|
|
102
118
|
throw enrichAiError(error);
|
|
@@ -107,12 +123,14 @@ function enrichAiError(error) {
|
|
|
107
123
|
const status = error?.response?.status;
|
|
108
124
|
const apiError = error?.response?.data?.error;
|
|
109
125
|
const apiCode = typeof apiError === 'object' ? apiError?.code : null;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (status === 403) return new Error('
|
|
113
|
-
if (status ===
|
|
114
|
-
if (
|
|
115
|
-
|
|
126
|
+
const apiType = typeof apiError === 'object' ? apiError?.type : null;
|
|
127
|
+
const quotaCodes = ['insufficient_quota', 'credit_balance_exhausted', 'billing_hard_limit_reached'];
|
|
128
|
+
if (status === 401 || status === 403) return new Error('OpenRouter rejected the configured CLI credential. Run `gent ai configure` with a valid key.');
|
|
129
|
+
if (status === 404) return new Error(`OpenRouter has no model "${getModel()}". Set GENT_AI_MODEL to a model id from https://openrouter.ai/models.`);
|
|
130
|
+
if (status === 402 || quotaCodes.includes(apiCode) || quotaCodes.includes(apiType)) {
|
|
131
|
+
return new Error('OpenRouter billing: this key has no credits left. Add credits at https://openrouter.ai/credits — the key itself is valid.');
|
|
132
|
+
}
|
|
133
|
+
if (status === 429) return new Error('Gent AI is rate limited. Retry in a moment.');
|
|
116
134
|
if (apiError?.message) return new Error(`Gent AI failed: ${apiError.message}`);
|
|
117
135
|
if (typeof apiError === 'string') return new Error(apiError);
|
|
118
136
|
return error;
|
|
@@ -141,12 +159,49 @@ async function resolveConflictHunk({ base, ours, theirs, fileName }) {
|
|
|
141
159
|
`BASE:\n${base || '(none)'}\n\n` +
|
|
142
160
|
`OURS:\n${ours}\n\n` +
|
|
143
161
|
`THEIRS:\n${theirs}`;
|
|
144
|
-
|
|
162
|
+
const response = await complete({ profile: 'merge', prompt, maxTokens: 1400 });
|
|
163
|
+
return parseMergeResolution(response);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseMergeResolution(response) {
|
|
167
|
+
const cleaned = response
|
|
168
|
+
.replace(/^```(?:json)?\s*/i, '')
|
|
169
|
+
.replace(/\s*```$/, '')
|
|
170
|
+
.trim();
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(cleaned);
|
|
173
|
+
if (typeof parsed.merged !== 'string') throw new Error('missing merged text');
|
|
174
|
+
return {
|
|
175
|
+
merged: parsed.merged,
|
|
176
|
+
summary: briefSummary(parsed.summary),
|
|
177
|
+
};
|
|
178
|
+
} catch {
|
|
179
|
+
return {
|
|
180
|
+
merged: response,
|
|
181
|
+
summary: 'Combined the conflicting changes.',
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function briefSummary(value) {
|
|
187
|
+
const summary = typeof value === 'string'
|
|
188
|
+
? value.replace(/\s+/g, ' ').trim()
|
|
189
|
+
: '';
|
|
190
|
+
if (!summary) return 'Combined the conflicting changes.';
|
|
191
|
+
const words = summary.split(' ');
|
|
192
|
+
const wordLimited = words.length <= 18
|
|
193
|
+
? summary
|
|
194
|
+
: `${words.slice(0, 18).join(' ')}...`;
|
|
195
|
+
return wordLimited.length <= 160
|
|
196
|
+
? wordLimited
|
|
197
|
+
: `${wordLimited.slice(0, 157).trimEnd()}...`;
|
|
145
198
|
}
|
|
146
199
|
|
|
147
200
|
module.exports = {
|
|
148
201
|
PROMPTS,
|
|
149
202
|
DEFAULT_MODEL,
|
|
203
|
+
MIN_OUTPUT_TOKENS,
|
|
204
|
+
REASONING_HEADROOM,
|
|
150
205
|
isEnabled,
|
|
151
206
|
getModel,
|
|
152
207
|
getApiKey,
|
|
@@ -159,5 +214,6 @@ module.exports = {
|
|
|
159
214
|
explainChanges,
|
|
160
215
|
reviewChanges,
|
|
161
216
|
resolveConflictHunk,
|
|
217
|
+
parseMergeResolution,
|
|
162
218
|
extractText,
|
|
163
219
|
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const fs = require('fs').promises;
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { GENT_DIR } = require('./constants');
|
|
5
|
+
|
|
6
|
+
const KEY_NAME = 'OPENROUTER_API_KEY';
|
|
7
|
+
// Written alongside the key so an install configured before the OpenRouter
|
|
8
|
+
// switch does not keep pinning a stale OpenAI model id.
|
|
9
|
+
const LEGACY_KEY_NAMES = ['OPENAI_API_KEY'];
|
|
10
|
+
|
|
11
|
+
function getEnvPath() {
|
|
12
|
+
return path.join(os.homedir(), GENT_DIR, '.env');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function validateApiKey(value) {
|
|
16
|
+
return typeof value === 'string' && /^sk-or-v1-[A-Za-z0-9]{32,}$/.test(value.trim());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function saveApiKey(value, { model } = {}) {
|
|
20
|
+
const apiKey = typeof value === 'string' ? value.trim() : '';
|
|
21
|
+
if (!validateApiKey(apiKey)) throw new Error('Enter a valid OpenRouter API key beginning with sk-or-v1-.');
|
|
22
|
+
|
|
23
|
+
const envPath = getEnvPath();
|
|
24
|
+
const directory = path.dirname(envPath);
|
|
25
|
+
const existing = await fs.readFile(envPath, 'utf8').catch(error => {
|
|
26
|
+
if (error.code === 'ENOENT') return '';
|
|
27
|
+
throw error;
|
|
28
|
+
});
|
|
29
|
+
const managed = [KEY_NAME, ...LEGACY_KEY_NAMES];
|
|
30
|
+
const lines = existing
|
|
31
|
+
.split(/\r?\n/)
|
|
32
|
+
.filter(line => !managed.some(name => new RegExp(`^\\s*${name}\\s*=`).test(line)))
|
|
33
|
+
// Drop any model pinned for a previous provider; the service default wins.
|
|
34
|
+
.filter(line => !/^\s*GENT_AI_MODEL\s*=/.test(line));
|
|
35
|
+
while (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
36
|
+
lines.push(`${KEY_NAME}=${apiKey}`);
|
|
37
|
+
if (model) lines.push(`GENT_AI_MODEL=${model}`);
|
|
38
|
+
|
|
39
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
40
|
+
const temporary = `${envPath}.tmp-${process.pid}`;
|
|
41
|
+
await fs.writeFile(temporary, lines.join('\n') + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
42
|
+
await fs.rename(temporary, envPath);
|
|
43
|
+
await fs.chmod(envPath, 0o600);
|
|
44
|
+
process.env[KEY_NAME] = apiKey;
|
|
45
|
+
for (const name of LEGACY_KEY_NAMES) delete process.env[name];
|
|
46
|
+
if (model) process.env.GENT_AI_MODEL = model;
|
|
47
|
+
else delete process.env.GENT_AI_MODEL;
|
|
48
|
+
return envPath;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { getEnvPath, validateApiKey, saveApiKey, KEY_NAME };
|