@hifullmoon/aicommit 1.4.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/.aicommit.config.example.json +118 -0
- package/CHANGELOG.md +131 -0
- package/LICENSE +21 -0
- package/README.md +406 -0
- package/README.zh-CN.md +408 -0
- package/SECURITY.md +33 -0
- package/bin/aicommit.js +47 -0
- package/docs/distribution.md +104 -0
- package/docs/examples/aicommit-policy.yml +24 -0
- package/docs/examples/commit-msg +4 -0
- package/docs/examples/extension/aicommit-extension.json +9 -0
- package/docs/examples/extension/index.mjs +32 -0
- package/docs/extensions.md +93 -0
- package/docs/privacy.md +58 -0
- package/docs/provider-compatibility.md +37 -0
- package/docs/provider-presets.md +100 -0
- package/docs/team-policy.md +86 -0
- package/docs/troubleshooting.md +37 -0
- package/package.json +100 -0
- package/presets/provider-presets.json +60 -0
- package/schemas/aicommit-extension.schema.json +27 -0
- package/schemas/aicommit-output.schema.json +86 -0
- package/schemas/aicommit-provider-presets.schema.json +56 -0
- package/schemas/aicommit-split-checkpoint.schema.json +92 -0
- package/schemas/aicommit-split-plan.schema.json +108 -0
- package/schemas/aicommit-team-policy.schema.json +59 -0
- package/src/api.js +740 -0
- package/src/cli.js +558 -0
- package/src/completion.js +136 -0
- package/src/config-command.js +59 -0
- package/src/config.js +524 -0
- package/src/context.js +537 -0
- package/src/credentials.js +123 -0
- package/src/doctor.js +131 -0
- package/src/errors.js +96 -0
- package/src/extension-runner.mjs +36 -0
- package/src/extensions.js +426 -0
- package/src/generation-ui.js +53 -0
- package/src/git.js +458 -0
- package/src/main.js +768 -0
- package/src/metrics.js +375 -0
- package/src/output.js +87 -0
- package/src/policy-command.js +172 -0
- package/src/policy.js +421 -0
- package/src/preset-command.js +91 -0
- package/src/provider-presets.js +361 -0
- package/src/providers.js +306 -0
- package/src/setup.js +268 -0
- package/src/split-checkpoint.js +252 -0
- package/src/split-hunks.js +263 -0
- package/src/split-plan.js +339 -0
- package/src/split.js +2285 -0
- package/src/team-policy.js +95 -0
- package/src/trust.js +31 -0
- package/src/ui.js +488 -0
- package/src/utils.js +165 -0
- package/templates/.aicommit.policy.json +22 -0
package/src/api.js
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import { cleanCommitMessage } from './utils.js';
|
|
2
|
+
import { getProviderAdapter, normalizeUsage } from './providers.js';
|
|
3
|
+
import { ERROR_CATEGORIES, fail } from './errors.js';
|
|
4
|
+
import {
|
|
5
|
+
buildCommitPolicyPrompt,
|
|
6
|
+
buildPolicyCorrectionPrompt,
|
|
7
|
+
normalizeCommitPolicy,
|
|
8
|
+
validateCommitCandidate,
|
|
9
|
+
} from './policy.js';
|
|
10
|
+
import { encodeUntrustedData } from './trust.js';
|
|
11
|
+
import { extensionHostFor, resolveProviderAdapter } from './extensions.js';
|
|
12
|
+
|
|
13
|
+
// Default per-request timeout; overridable via the "timeoutMs" config key.
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
15
|
+
|
|
16
|
+
export async function callAPI(
|
|
17
|
+
apiUrl,
|
|
18
|
+
apiKey,
|
|
19
|
+
modelId,
|
|
20
|
+
messages,
|
|
21
|
+
temperature,
|
|
22
|
+
maxTokens,
|
|
23
|
+
timeoutMs,
|
|
24
|
+
extraBody = {},
|
|
25
|
+
reasoning = null,
|
|
26
|
+
stream = null,
|
|
27
|
+
options = {},
|
|
28
|
+
) {
|
|
29
|
+
const result = await requestGeneration(
|
|
30
|
+
{
|
|
31
|
+
apiUrl,
|
|
32
|
+
apiKey,
|
|
33
|
+
modelId,
|
|
34
|
+
timeoutMs,
|
|
35
|
+
extraBody,
|
|
36
|
+
reasoning,
|
|
37
|
+
providerType: options.providerType,
|
|
38
|
+
retry: options.retry,
|
|
39
|
+
},
|
|
40
|
+
{ messages, temperature, maxTokens, stream },
|
|
41
|
+
);
|
|
42
|
+
return result.raw;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_RETRY_POLICY = Object.freeze({
|
|
46
|
+
maxAttempts: 3,
|
|
47
|
+
baseDelayMs: 500,
|
|
48
|
+
maxDelayMs: 5000,
|
|
49
|
+
});
|
|
50
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
51
|
+
const RETRYABLE_NETWORK_CODES = new Set([
|
|
52
|
+
'ECONNRESET',
|
|
53
|
+
'ECONNREFUSED',
|
|
54
|
+
'EHOSTUNREACH',
|
|
55
|
+
'ENETUNREACH',
|
|
56
|
+
'EPIPE',
|
|
57
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
58
|
+
'UND_ERR_SOCKET',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
function secureEndpoint(apiUrl) {
|
|
62
|
+
const endpoint = new URL(apiUrl);
|
|
63
|
+
const loopback =
|
|
64
|
+
endpoint.hostname === 'localhost' ||
|
|
65
|
+
endpoint.hostname === '127.0.0.1' ||
|
|
66
|
+
endpoint.hostname.startsWith('127.') ||
|
|
67
|
+
endpoint.hostname === '[::1]';
|
|
68
|
+
if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && loopback)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
'Refusing insecure API endpoint: use HTTPS, or HTTP only for localhost/loopback.',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return endpoint;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function retryPolicy(value = {}) {
|
|
77
|
+
return {
|
|
78
|
+
maxAttempts: value?.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts,
|
|
79
|
+
baseDelayMs: value?.baseDelayMs ?? DEFAULT_RETRY_POLICY.baseDelayMs,
|
|
80
|
+
maxDelayMs: value?.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs,
|
|
81
|
+
sleep:
|
|
82
|
+
value?.sleep ??
|
|
83
|
+
((delayMs) =>
|
|
84
|
+
new Promise((resolve) => {
|
|
85
|
+
globalThis.setTimeout(resolve, delayMs);
|
|
86
|
+
})),
|
|
87
|
+
now: value?.now ?? (() => Date.now()),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function retryAfterMs(value, now) {
|
|
92
|
+
if (!value) return null;
|
|
93
|
+
const seconds = Number(value);
|
|
94
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
95
|
+
const date = Date.parse(value);
|
|
96
|
+
if (Number.isNaN(date)) return null;
|
|
97
|
+
return Math.max(0, date - now());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function networkFailure(err) {
|
|
101
|
+
if (err instanceof TypeError) return true;
|
|
102
|
+
return RETRYABLE_NETWORK_CODES.has(err?.code) || RETRYABLE_NETWORK_CODES.has(err?.cause?.code);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function timeoutError(err, timeout) {
|
|
106
|
+
if (err?.name !== 'TimeoutError' && err?.name !== 'AbortError') return null;
|
|
107
|
+
return new Error(
|
|
108
|
+
`Request timed out after ${Math.round(timeout / 1000)}s — the model took too long to respond. ` +
|
|
109
|
+
`Raise "timeoutMs" in your config if this keeps happening.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function fetchWithRetry(apiUrl, init, timeout, configuredPolicy, consume) {
|
|
114
|
+
const policy = retryPolicy(configuredPolicy);
|
|
115
|
+
let attempt = 0;
|
|
116
|
+
|
|
117
|
+
while (attempt < policy.maxAttempts) {
|
|
118
|
+
attempt += 1;
|
|
119
|
+
let response;
|
|
120
|
+
try {
|
|
121
|
+
response = await fetch(apiUrl, {
|
|
122
|
+
...init,
|
|
123
|
+
signal: AbortSignal.timeout(timeout),
|
|
124
|
+
});
|
|
125
|
+
} catch (err) {
|
|
126
|
+
const wrappedTimeout = timeoutError(err, timeout);
|
|
127
|
+
if (wrappedTimeout) throw wrappedTimeout;
|
|
128
|
+
if (!networkFailure(err) || attempt >= policy.maxAttempts) throw err;
|
|
129
|
+
const delay = Math.min(policy.baseDelayMs * 2 ** (attempt - 1), policy.maxDelayMs);
|
|
130
|
+
await policy.sleep(delay);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (response.ok) {
|
|
135
|
+
try {
|
|
136
|
+
return { value: await consume(response), attempts: attempt };
|
|
137
|
+
} catch (err) {
|
|
138
|
+
const wrappedTimeout = timeoutError(err, timeout);
|
|
139
|
+
if (wrappedTimeout) throw wrappedTimeout;
|
|
140
|
+
if (!networkFailure(err) || attempt >= policy.maxAttempts) throw err;
|
|
141
|
+
const delay = Math.min(policy.baseDelayMs * 2 ** (attempt - 1), policy.maxDelayMs);
|
|
142
|
+
await policy.sleep(delay);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (RETRYABLE_STATUS.has(response.status) && attempt < policy.maxAttempts) {
|
|
147
|
+
const requestedDelay = retryAfterMs(response.headers.get('retry-after'), policy.now);
|
|
148
|
+
const delay = Math.min(
|
|
149
|
+
requestedDelay ?? policy.baseDelayMs * 2 ** (attempt - 1),
|
|
150
|
+
policy.maxDelayMs,
|
|
151
|
+
);
|
|
152
|
+
await response.body?.cancel().catch(() => {});
|
|
153
|
+
await policy.sleep(delay);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const errText = await response.text();
|
|
158
|
+
throw new Error(`HTTP ${response.status}: ${errText.slice(0, 400)}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
throw new Error('Provider request exhausted its retry budget.');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Unified provider request contract. Provider adapters own request dialects
|
|
165
|
+
// and response normalization; callers receive the same shape regardless of
|
|
166
|
+
// whether the endpoint is OpenAI-compatible or native Ollama.
|
|
167
|
+
export async function requestGeneration(config, request) {
|
|
168
|
+
secureEndpoint(config.apiUrl);
|
|
169
|
+
const timeout = config.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
170
|
+
const adapter = await resolveProviderAdapter(config, getProviderAdapter);
|
|
171
|
+
const payload = await adapter.buildRequest({
|
|
172
|
+
messages: request.messages,
|
|
173
|
+
temperature: request.temperature,
|
|
174
|
+
maxTokens: request.maxTokens,
|
|
175
|
+
extraBody: config.extraBody,
|
|
176
|
+
reasoning: request.reasoning ?? config.reasoning,
|
|
177
|
+
streaming: Boolean(request.stream?.onReasoningDelta),
|
|
178
|
+
});
|
|
179
|
+
const startedAt = performance.now();
|
|
180
|
+
const { value: consumed, attempts } = await fetchWithRetry(
|
|
181
|
+
config.apiUrl,
|
|
182
|
+
{
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: {
|
|
185
|
+
'Content-Type': 'application/json',
|
|
186
|
+
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
|
|
187
|
+
...adapter.headers,
|
|
188
|
+
},
|
|
189
|
+
body: JSON.stringify(payload),
|
|
190
|
+
},
|
|
191
|
+
timeout,
|
|
192
|
+
config.retry,
|
|
193
|
+
async (response) => {
|
|
194
|
+
const contentType = response.headers.get('content-type') || '';
|
|
195
|
+
if (payload.stream && contentType.includes('text/event-stream')) {
|
|
196
|
+
return {
|
|
197
|
+
data: await consumeEventStream(response, request.stream.onReasoningDelta),
|
|
198
|
+
eventStream: true,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
return { data: await response.json(), eventStream: false };
|
|
203
|
+
} catch (err) {
|
|
204
|
+
if (err instanceof SyntaxError) {
|
|
205
|
+
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Provider returned invalid JSON.', {
|
|
206
|
+
cause: err,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const { data, eventStream } = consumed;
|
|
215
|
+
const normalized = await adapter.normalizeResponse(data);
|
|
216
|
+
if (request.stream?.onReasoningDelta && !eventStream && normalized.reasoning) {
|
|
217
|
+
request.stream.onReasoningDelta(normalized.reasoning);
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
...normalized,
|
|
221
|
+
capabilities: adapter.capabilities,
|
|
222
|
+
attempts,
|
|
223
|
+
latencyMs: performance.now() - startedAt,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function streamContent(value) {
|
|
228
|
+
if (typeof value === 'string') return value;
|
|
229
|
+
if (Array.isArray(value)) {
|
|
230
|
+
return value
|
|
231
|
+
.map((part) => part?.text ?? part?.content ?? '')
|
|
232
|
+
.filter(Boolean)
|
|
233
|
+
.join('');
|
|
234
|
+
}
|
|
235
|
+
return value?.text ?? '';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Consume OpenAI-compatible SSE (`data: {...}` / `data: [DONE]`) while
|
|
239
|
+
// assembling a normal Chat Completions-shaped response for the existing
|
|
240
|
+
// parsing and retry pipeline. Reasoning fields differ by provider, so every
|
|
241
|
+
// delta goes through the same normalization used for non-stream responses.
|
|
242
|
+
async function consumeEventStream(response, onReasoningDelta) {
|
|
243
|
+
if (!response.body) throw new Error('Streaming response did not include a body.');
|
|
244
|
+
|
|
245
|
+
const reader = response.body.getReader();
|
|
246
|
+
const decoder = new TextDecoder();
|
|
247
|
+
let buffer = '';
|
|
248
|
+
let dataLines = [];
|
|
249
|
+
let content = '';
|
|
250
|
+
let reasoning = '';
|
|
251
|
+
let usage = null;
|
|
252
|
+
let model = null;
|
|
253
|
+
let finishReason = null;
|
|
254
|
+
let completed = false;
|
|
255
|
+
|
|
256
|
+
const consumeData = (raw) => {
|
|
257
|
+
const payloadText = raw.trim();
|
|
258
|
+
if (!payloadText) return;
|
|
259
|
+
if (payloadText === '[DONE]') {
|
|
260
|
+
completed = true;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let event;
|
|
265
|
+
try {
|
|
266
|
+
event = JSON.parse(payloadText);
|
|
267
|
+
} catch {
|
|
268
|
+
throw new Error(`Invalid JSON in streaming response: ${payloadText.slice(0, 200)}`);
|
|
269
|
+
}
|
|
270
|
+
if (event.error) {
|
|
271
|
+
const message = event.error.message || JSON.stringify(event.error);
|
|
272
|
+
throw new Error(`Streaming API error: ${message}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
model ||= event.model || null;
|
|
276
|
+
if (event.usage) usage = event.usage;
|
|
277
|
+
const finishedChoice = event?.choices?.find((choice) => choice?.finish_reason != null);
|
|
278
|
+
if (finishedChoice) {
|
|
279
|
+
completed = true;
|
|
280
|
+
finishReason ||= finishedChoice.finish_reason;
|
|
281
|
+
}
|
|
282
|
+
const delta = event?.choices?.[0]?.delta ?? event?.choices?.[0]?.message;
|
|
283
|
+
if (!delta) return;
|
|
284
|
+
|
|
285
|
+
content += streamContent(delta.content);
|
|
286
|
+
const reasoningDelta = extractReasoning(delta);
|
|
287
|
+
if (reasoningDelta) {
|
|
288
|
+
reasoning += reasoningDelta;
|
|
289
|
+
onReasoningDelta(reasoningDelta);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const consumeLine = (line) => {
|
|
294
|
+
if (line === '') {
|
|
295
|
+
if (dataLines.length) consumeData(dataLines.join('\n'));
|
|
296
|
+
dataLines = [];
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart());
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
while (true) {
|
|
303
|
+
const { value, done } = await reader.read();
|
|
304
|
+
if (done) break;
|
|
305
|
+
buffer += decoder.decode(value, { stream: true });
|
|
306
|
+
const lines = buffer.split(/\r?\n/);
|
|
307
|
+
buffer = lines.pop() || '';
|
|
308
|
+
for (const line of lines) consumeLine(line);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
buffer += decoder.decode();
|
|
312
|
+
if (buffer) consumeLine(buffer);
|
|
313
|
+
if (dataLines.length) consumeData(dataLines.join('\n'));
|
|
314
|
+
|
|
315
|
+
if (!completed) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
'Streaming response ended before the provider sent [DONE] or a finish_reason. ' +
|
|
318
|
+
'The partial response was discarded; retry the request.',
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const message = { content: content || null };
|
|
323
|
+
if (reasoning) message.reasoning_content = reasoning;
|
|
324
|
+
return { model, choices: [{ message, finish_reason: finishReason }], usage };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Minimal "ping" request to verify the endpoint, API key, and model are all
|
|
328
|
+
// reachable. Throws on HTTP errors (same as callAPI); returns latency, the
|
|
329
|
+
// echoed model id, and a preview of the model's reply. Uses the same request
|
|
330
|
+
// body as a real call, so it validates the actual path a commit would take.
|
|
331
|
+
export async function checkConnection(config, stream = null) {
|
|
332
|
+
const { maxTokens, reasoning } = config;
|
|
333
|
+
const t0 = performance.now();
|
|
334
|
+
|
|
335
|
+
const result = await requestGeneration(config, {
|
|
336
|
+
messages: [{ role: 'user', content: 'Reply with exactly: OK' }],
|
|
337
|
+
temperature: 0,
|
|
338
|
+
maxTokens:
|
|
339
|
+
reasoning?.mode === 'on'
|
|
340
|
+
? Math.max(Math.min(maxTokens || 1024, 64), reasoning.maxTokens || 4096)
|
|
341
|
+
: Math.min(maxTokens || 1024, 64),
|
|
342
|
+
stream,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
elapsed: performance.now() - t0,
|
|
347
|
+
model: result.model,
|
|
348
|
+
provider: result.provider,
|
|
349
|
+
capabilities: result.capabilities,
|
|
350
|
+
content: result.content.trim(),
|
|
351
|
+
reasoning: result.reasoning,
|
|
352
|
+
usage: result.usage,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Tail of reasoning sent back in a follow-up call. The conclusion lives at
|
|
357
|
+
// the end; re-sending a whole trace (sometimes tens of thousands of tokens)
|
|
358
|
+
// would be slow and expensive for no benefit.
|
|
359
|
+
const MAX_REASONING_CHARS = 8000;
|
|
360
|
+
|
|
361
|
+
// OpenAI-compatible providers use a few different values when the output
|
|
362
|
+
// budget is exhausted. Treat all known token-limit variants alike, while a
|
|
363
|
+
// normal `stop` (or a provider omitting finish_reason) remains untouched.
|
|
364
|
+
function hitTokenLimit(data) {
|
|
365
|
+
const reason = data?.finishReason;
|
|
366
|
+
return typeof reason === 'string' && /^(?:length|max_tokens|max_output_tokens)$/i.test(reason);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// A formatting follow-up does not need to repeat reasoning that has already
|
|
370
|
+
// happened. Disable it only for providers where we know the switch is valid;
|
|
371
|
+
// unknown compatible endpoints keep their configured behavior.
|
|
372
|
+
async function reasoningForFollowUp(config) {
|
|
373
|
+
const adapter = await resolveProviderAdapter(config, getProviderAdapter);
|
|
374
|
+
return adapter.reasoningForFollowUp(config.reasoning);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Prompt for a regenerate request: the model already saw the diff on the
|
|
378
|
+
// first call and produced a message for it, so the diff is NOT re-sent —
|
|
379
|
+
// rewording its own previous reply is enough, and far cheaper than resending
|
|
380
|
+
// what can be tens of thousands of tokens (same trade-off as correctivePrompt).
|
|
381
|
+
function regeneratePrompt(previousMessage, policy) {
|
|
382
|
+
return [
|
|
383
|
+
'You previously generated this commit message for the change:',
|
|
384
|
+
'',
|
|
385
|
+
previousMessage.slice(0, 1000),
|
|
386
|
+
'',
|
|
387
|
+
'Generate a DIFFERENT commit message for the same change — different wording or emphasis. ' +
|
|
388
|
+
`Keep commitPolicy v${policy.version}; allowed types: ${policy.types.join(', ')}. ` +
|
|
389
|
+
'Use first line "<type>[optional scope][optional !]: <subject>", ' +
|
|
390
|
+
'then an optional body after a blank line. ' +
|
|
391
|
+
'Output ONLY the new message — no explanation, no quotes, no code fences.',
|
|
392
|
+
].join('\n');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Normalize reasoning from the vendor-specific fields that can carry it:
|
|
396
|
+
// OpenAI-style `reasoning_content` (DeepSeek), OpenRouter-style `reasoning`,
|
|
397
|
+
// MiniMax/OpenRouter-style `reasoning_details` ([{ type: 'thinking', text }],
|
|
398
|
+
// possibly multiple segments with interleaved thinking).
|
|
399
|
+
function reasoningText(value) {
|
|
400
|
+
if (typeof value === 'string') return value;
|
|
401
|
+
if (Array.isArray(value)) {
|
|
402
|
+
return value.map(reasoningText).filter(Boolean).join('\n');
|
|
403
|
+
}
|
|
404
|
+
if (value && typeof value === 'object') {
|
|
405
|
+
return reasoningText(value.text ?? value.summary ?? value.content);
|
|
406
|
+
}
|
|
407
|
+
return '';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function extractReasoning(msg0) {
|
|
411
|
+
return (
|
|
412
|
+
reasoningText(msg0?.reasoning_content) ||
|
|
413
|
+
reasoningText(msg0?.reasoning) ||
|
|
414
|
+
reasoningText(msg0?.reasoning_details) ||
|
|
415
|
+
null
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Last-ditch extraction from raw reasoning text: prefer the first line that
|
|
420
|
+
// carries a conventional-commit prefix, else fall back to the last non-empty
|
|
421
|
+
// line.
|
|
422
|
+
function extractFromReasoning(reasoning, policy) {
|
|
423
|
+
const typePattern = policy.types
|
|
424
|
+
.map((type) => type.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
425
|
+
.join('|');
|
|
426
|
+
const commitType = new RegExp(`\\b(?:${typePattern})(?:\\([^()\\r\\n]+\\))?!?:\\s+\\S`, 'i');
|
|
427
|
+
const lines = reasoning.split('\n');
|
|
428
|
+
for (const line of lines) {
|
|
429
|
+
const idx = line.search(commitType);
|
|
430
|
+
if (idx !== -1) return line.slice(idx).trim();
|
|
431
|
+
}
|
|
432
|
+
const nonEmpty = lines.filter((l) => l.trim());
|
|
433
|
+
return nonEmpty[nonEmpty.length - 1]?.trim() || '';
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Prompt for the follow-up call when a reasoning model returned only a
|
|
437
|
+
// reasoning trace and no content.
|
|
438
|
+
function followupCommitPrompt(policy) {
|
|
439
|
+
return (
|
|
440
|
+
'Based on your analysis above, output ONLY the final commitPolicy v1 message. ' +
|
|
441
|
+
`Use one of these types: ${policy.types.join(', ')}. ` +
|
|
442
|
+
'Do not include any other text, explanation, or code fences.'
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Combine usage across every API call in one round. Reasoning models can
|
|
447
|
+
// trigger a follow-up call (see getResponseText); summing both keeps the
|
|
448
|
+
// reported token count honest instead of dropping the reasoning tokens.
|
|
449
|
+
function sumUsage(...usages) {
|
|
450
|
+
const total = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
451
|
+
let hasInput = false;
|
|
452
|
+
let hasOutput = false;
|
|
453
|
+
let hasTotal = false;
|
|
454
|
+
for (const u of usages) {
|
|
455
|
+
const normalized = normalizeUsage(u);
|
|
456
|
+
if (!normalized) continue;
|
|
457
|
+
if (typeof normalized.inputTokens === 'number') {
|
|
458
|
+
total.inputTokens += normalized.inputTokens;
|
|
459
|
+
hasInput = true;
|
|
460
|
+
}
|
|
461
|
+
if (typeof normalized.outputTokens === 'number') {
|
|
462
|
+
total.outputTokens += normalized.outputTokens;
|
|
463
|
+
hasOutput = true;
|
|
464
|
+
}
|
|
465
|
+
if (typeof normalized.totalTokens === 'number') {
|
|
466
|
+
total.totalTokens += normalized.totalTokens;
|
|
467
|
+
hasTotal = true;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (!hasInput && !hasOutput && !hasTotal) return null;
|
|
471
|
+
return {
|
|
472
|
+
...(hasInput ? { inputTokens: total.inputTokens } : {}),
|
|
473
|
+
...(hasOutput ? { outputTokens: total.outputTokens } : {}),
|
|
474
|
+
...(hasTotal ? { totalTokens: total.totalTokens } : {}),
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Make the API call and return the assistant text plus reasoning accumulated
|
|
479
|
+
// across the initial and any follow-up response. Reasoning models that can't disable thinking (MiniMax M2.x,
|
|
480
|
+
// DeepSeek R1, OpenRouter reasoning models) may return empty content with a
|
|
481
|
+
// reasoning trace; in that case a follow-up call feeds the (truncated) tail of
|
|
482
|
+
// the reasoning back as context so the model produces the final answer — the
|
|
483
|
+
// original messages (which include the full diff) are NOT re-sent, since the
|
|
484
|
+
// reasoning tail already carries the model's own analysis of them.
|
|
485
|
+
// Shared by the commit flow and the split flow. `usage` aggregates the token
|
|
486
|
+
// counts of every call made in the round.
|
|
487
|
+
export async function getResponseText(
|
|
488
|
+
config,
|
|
489
|
+
messages,
|
|
490
|
+
temperature,
|
|
491
|
+
maxTokens,
|
|
492
|
+
followUpPrompt,
|
|
493
|
+
stream = null,
|
|
494
|
+
responseValidator = null,
|
|
495
|
+
) {
|
|
496
|
+
let response = await requestGeneration(config, {
|
|
497
|
+
messages,
|
|
498
|
+
temperature,
|
|
499
|
+
maxTokens,
|
|
500
|
+
stream,
|
|
501
|
+
});
|
|
502
|
+
const usages = [response.usage];
|
|
503
|
+
let reasoning = response.reasoning;
|
|
504
|
+
let text = response.content;
|
|
505
|
+
const truncatedByLimit = hitTokenLimit(response);
|
|
506
|
+
const invalidResponse = typeof responseValidator === 'function' && !responseValidator(text);
|
|
507
|
+
|
|
508
|
+
if ((!text.trim() && reasoning) || truncatedByLimit || invalidResponse) {
|
|
509
|
+
const truncated =
|
|
510
|
+
(reasoning || '').length > MAX_REASONING_CHARS
|
|
511
|
+
? '…' + reasoning.slice(-MAX_REASONING_CHARS)
|
|
512
|
+
: reasoning || '';
|
|
513
|
+
|
|
514
|
+
const partial = text.trim();
|
|
515
|
+
const recoveryPrompt =
|
|
516
|
+
truncatedByLimit || invalidResponse
|
|
517
|
+
? `The previous response was ${
|
|
518
|
+
truncatedByLimit ? 'cut off by the provider token limit' : 'incomplete or malformed'
|
|
519
|
+
}. ` +
|
|
520
|
+
'Reproduce the COMPLETE answer from the beginning; do not continue from the cut-off point. ' +
|
|
521
|
+
'Keep the answer concise.\n\n' +
|
|
522
|
+
followUpPrompt
|
|
523
|
+
: followUpPrompt;
|
|
524
|
+
|
|
525
|
+
// With reasoning, its conclusion plus the partial answer is enough to
|
|
526
|
+
// reconstruct the output without paying to send the original diff again.
|
|
527
|
+
// A non-reasoning model has no such summary, so retain the original
|
|
528
|
+
// messages for the rare case where its response itself hit the limit.
|
|
529
|
+
const systemMsg = messages.find((m) => m.role === 'system');
|
|
530
|
+
const recoveryMessages = reasoning
|
|
531
|
+
? [
|
|
532
|
+
...(systemMsg ? [systemMsg] : []),
|
|
533
|
+
{
|
|
534
|
+
role: 'assistant',
|
|
535
|
+
content: partial
|
|
536
|
+
? `${truncated}\n\nPartial response (discard and replace):\n${partial.slice(-MAX_REASONING_CHARS)}`
|
|
537
|
+
: truncated,
|
|
538
|
+
},
|
|
539
|
+
{ role: 'user', content: recoveryPrompt },
|
|
540
|
+
]
|
|
541
|
+
: [
|
|
542
|
+
...messages,
|
|
543
|
+
...(partial ? [{ role: 'assistant', content: partial.slice(-MAX_REASONING_CHARS) }] : []),
|
|
544
|
+
{ role: 'user', content: recoveryPrompt },
|
|
545
|
+
];
|
|
546
|
+
|
|
547
|
+
// Respect the caller's configured ceiling. Known reasoning providers are
|
|
548
|
+
// switched to formatting-only mode above, and the recovery prompt asks for
|
|
549
|
+
// a compact answer, so the same budget has substantially more useful room.
|
|
550
|
+
response = await requestGeneration(config, {
|
|
551
|
+
messages: recoveryMessages,
|
|
552
|
+
temperature,
|
|
553
|
+
maxTokens,
|
|
554
|
+
reasoning: await reasoningForFollowUp(config),
|
|
555
|
+
stream,
|
|
556
|
+
});
|
|
557
|
+
usages.push(response.usage);
|
|
558
|
+
const followUpReasoning = response.reasoning;
|
|
559
|
+
if (followUpReasoning) {
|
|
560
|
+
reasoning = [reasoning, followUpReasoning].filter(Boolean).join('\n\n');
|
|
561
|
+
}
|
|
562
|
+
text = response.content;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return { text, data: response.raw, response, reasoning, usage: sumUsage(...usages) };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function buildCommitMessages(config, diff, regenerateCount = 0, previousMessage = '') {
|
|
569
|
+
const { prompt, temperature, language, regenerateWithDiff } = config;
|
|
570
|
+
const policy = normalizeCommitPolicy(config.commitPolicy, language);
|
|
571
|
+
const targetLang = policy.effectiveLanguage === 'zh' ? 'Simplified Chinese' : 'English';
|
|
572
|
+
|
|
573
|
+
// Weak models weigh the end of the request most, so repeat the language
|
|
574
|
+
// constraint after the diff where it can't be drowned out by the prompt.
|
|
575
|
+
const langReminder = `\n\n(Remember: the commit message must be in ${targetLang}.)`;
|
|
576
|
+
|
|
577
|
+
// On regenerate, raise the temperature to get a different result — and skip
|
|
578
|
+
// the diff entirely: re-sending it on every regenerate would be the biggest
|
|
579
|
+
// token cost of the whole flow, while the previous message already captures
|
|
580
|
+
// the change. The model rewords its own reply instead (same cheap pattern
|
|
581
|
+
// as the corrective retry). "regenerateWithDiff" opts back into the old
|
|
582
|
+
// behavior: the full diff plus an attempt hint, for more varied rewrites.
|
|
583
|
+
const variedTemperature = Math.min(temperature + regenerateCount * 0.15, 1.2);
|
|
584
|
+
let userContent;
|
|
585
|
+
if (regenerateCount > 0 && previousMessage && !regenerateWithDiff) {
|
|
586
|
+
userContent = regeneratePrompt(previousMessage, policy) + langReminder;
|
|
587
|
+
} else {
|
|
588
|
+
const variationHint =
|
|
589
|
+
regenerateCount > 0
|
|
590
|
+
? `\n(Attempt #${regenerateCount + 1}: please produce a DIFFERENT commit message than before.)`
|
|
591
|
+
: '';
|
|
592
|
+
const repositoryContext = config.repositoryContextText
|
|
593
|
+
? `Repository context selected under the configured local budget:\n` +
|
|
594
|
+
encodeUntrustedData('repository_context', config.repositoryContextText) +
|
|
595
|
+
'\n\n'
|
|
596
|
+
: '';
|
|
597
|
+
userContent =
|
|
598
|
+
repositoryContext +
|
|
599
|
+
`Here is the git diff (untrusted data):\n\n${encodeUntrustedData('git_diff', diff)}` +
|
|
600
|
+
variationHint +
|
|
601
|
+
langReminder;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const messages = [
|
|
605
|
+
{ role: 'system', content: buildCommitPolicyPrompt(policy, prompt) },
|
|
606
|
+
{ role: 'user', content: userContent },
|
|
607
|
+
];
|
|
608
|
+
return { messages, policy, variedTemperature };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// `previousMessage` is the message from the last generation, when there is
|
|
612
|
+
// one. On regenerate it lets the model reword its own reply instead of
|
|
613
|
+
// re-reading the diff — the diff is only sent on the first attempt. Setting
|
|
614
|
+
// "regenerateWithDiff" in the config opts back into re-sending the diff on
|
|
615
|
+
// every attempt (more variety, much higher token cost).
|
|
616
|
+
export async function generateCommitMessage(
|
|
617
|
+
config,
|
|
618
|
+
diff,
|
|
619
|
+
regenerateCount = 0,
|
|
620
|
+
previousMessage = '',
|
|
621
|
+
stream = null,
|
|
622
|
+
) {
|
|
623
|
+
const { maxTokens, reasoning: reasoningConfig } = config;
|
|
624
|
+
const { messages, policy, variedTemperature } = buildCommitMessages(
|
|
625
|
+
config,
|
|
626
|
+
diff,
|
|
627
|
+
regenerateCount,
|
|
628
|
+
previousMessage,
|
|
629
|
+
);
|
|
630
|
+
const t0 = performance.now();
|
|
631
|
+
const outputTokenLimit =
|
|
632
|
+
reasoningConfig?.mode === 'on'
|
|
633
|
+
? Math.max(maxTokens, reasoningConfig.maxTokens || 4096)
|
|
634
|
+
: maxTokens;
|
|
635
|
+
|
|
636
|
+
const {
|
|
637
|
+
text,
|
|
638
|
+
data,
|
|
639
|
+
reasoning: initialReasoning,
|
|
640
|
+
usage: firstUsage,
|
|
641
|
+
} = await getResponseText(
|
|
642
|
+
config,
|
|
643
|
+
messages,
|
|
644
|
+
variedTemperature,
|
|
645
|
+
outputTokenLimit,
|
|
646
|
+
followupCommitPrompt(policy),
|
|
647
|
+
stream,
|
|
648
|
+
);
|
|
649
|
+
let usage = firstUsage;
|
|
650
|
+
let reasoning = initialReasoning;
|
|
651
|
+
let message = text;
|
|
652
|
+
let corrections = 0;
|
|
653
|
+
|
|
654
|
+
// Last resort: extract a message from the reasoning content itself
|
|
655
|
+
if (!message.trim() && reasoning) {
|
|
656
|
+
message = extractFromReasoning(reasoning, policy);
|
|
657
|
+
}
|
|
658
|
+
message = cleanCommitMessage(message);
|
|
659
|
+
|
|
660
|
+
// Validate against the versioned policy and give the provider exactly one
|
|
661
|
+
// cheap correction attempt. The diff is never re-sent: the prior reply plus
|
|
662
|
+
// concrete violations are sufficient to repair formatting and constraints.
|
|
663
|
+
const validate = async (candidate) => {
|
|
664
|
+
const builtIn = validateCommitCandidate(candidate, { policy, diff });
|
|
665
|
+
const host = extensionHostFor(config);
|
|
666
|
+
if (!host) return builtIn;
|
|
667
|
+
const extensionIssues = await host.validateMessage(candidate, policy);
|
|
668
|
+
const issues = [...builtIn.issues, ...extensionIssues];
|
|
669
|
+
const errors = issues.filter((item) => item.severity === 'error');
|
|
670
|
+
const warnings = issues.filter((item) => item.severity === 'warning');
|
|
671
|
+
return {
|
|
672
|
+
...builtIn,
|
|
673
|
+
valid: errors.length === 0,
|
|
674
|
+
needsCorrection: errors.length > 0,
|
|
675
|
+
issues,
|
|
676
|
+
errors,
|
|
677
|
+
warnings,
|
|
678
|
+
};
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
let validation = await validate(message);
|
|
682
|
+
if (message.trim() && validation.needsCorrection) {
|
|
683
|
+
corrections = 1;
|
|
684
|
+
const retry = await getResponseText(
|
|
685
|
+
config,
|
|
686
|
+
[
|
|
687
|
+
messages[0],
|
|
688
|
+
{
|
|
689
|
+
role: 'user',
|
|
690
|
+
content: buildPolicyCorrectionPrompt(message, validation.errors, policy),
|
|
691
|
+
},
|
|
692
|
+
],
|
|
693
|
+
variedTemperature,
|
|
694
|
+
outputTokenLimit,
|
|
695
|
+
followupCommitPrompt(policy),
|
|
696
|
+
stream,
|
|
697
|
+
);
|
|
698
|
+
const fixed = cleanCommitMessage(retry.text);
|
|
699
|
+
// The retry is a real API call that cost tokens regardless of whether it
|
|
700
|
+
// produced a usable message — count its usage unconditionally.
|
|
701
|
+
usage = sumUsage(usage, retry.usage);
|
|
702
|
+
if (retry.reasoning) {
|
|
703
|
+
reasoning = [reasoning, retry.reasoning].filter(Boolean).join('\n\n');
|
|
704
|
+
}
|
|
705
|
+
if (fixed.trim()) {
|
|
706
|
+
message = fixed;
|
|
707
|
+
}
|
|
708
|
+
validation = await validate(message);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const elapsed = performance.now() - t0;
|
|
712
|
+
|
|
713
|
+
if (!message.trim()) {
|
|
714
|
+
const snippet = JSON.stringify(data, null, 2).slice(0, 600);
|
|
715
|
+
throw new Error(
|
|
716
|
+
`API returned an empty commit message.\n` +
|
|
717
|
+
` The request succeeded but no text came back — the model may have spent ` +
|
|
718
|
+
`its token budget on reasoning (maxTokens: ${outputTokenLimit}).\n` +
|
|
719
|
+
` Try raising "maxTokens" in your config.\n\nRaw response:\n${snippet}`,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (!validation.valid) {
|
|
724
|
+
const details = validation.errors.map((item) => item.message).join(' ');
|
|
725
|
+
throw fail(
|
|
726
|
+
ERROR_CATEGORIES.RESPONSE_FORMAT,
|
|
727
|
+
'API returned a commit message that violates commitPolicy after the corrective retry. ' +
|
|
728
|
+
details,
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return {
|
|
733
|
+
message: cleanCommitMessage(message),
|
|
734
|
+
elapsed,
|
|
735
|
+
usage,
|
|
736
|
+
reasoning,
|
|
737
|
+
qualityWarnings: validation.warnings.map((item) => item.message),
|
|
738
|
+
corrections,
|
|
739
|
+
};
|
|
740
|
+
}
|