@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.
Files changed (57) hide show
  1. package/.aicommit.config.example.json +118 -0
  2. package/CHANGELOG.md +131 -0
  3. package/LICENSE +21 -0
  4. package/README.md +406 -0
  5. package/README.zh-CN.md +408 -0
  6. package/SECURITY.md +33 -0
  7. package/bin/aicommit.js +47 -0
  8. package/docs/distribution.md +104 -0
  9. package/docs/examples/aicommit-policy.yml +24 -0
  10. package/docs/examples/commit-msg +4 -0
  11. package/docs/examples/extension/aicommit-extension.json +9 -0
  12. package/docs/examples/extension/index.mjs +32 -0
  13. package/docs/extensions.md +93 -0
  14. package/docs/privacy.md +58 -0
  15. package/docs/provider-compatibility.md +37 -0
  16. package/docs/provider-presets.md +100 -0
  17. package/docs/team-policy.md +86 -0
  18. package/docs/troubleshooting.md +37 -0
  19. package/package.json +100 -0
  20. package/presets/provider-presets.json +60 -0
  21. package/schemas/aicommit-extension.schema.json +27 -0
  22. package/schemas/aicommit-output.schema.json +86 -0
  23. package/schemas/aicommit-provider-presets.schema.json +56 -0
  24. package/schemas/aicommit-split-checkpoint.schema.json +92 -0
  25. package/schemas/aicommit-split-plan.schema.json +108 -0
  26. package/schemas/aicommit-team-policy.schema.json +59 -0
  27. package/src/api.js +740 -0
  28. package/src/cli.js +558 -0
  29. package/src/completion.js +136 -0
  30. package/src/config-command.js +59 -0
  31. package/src/config.js +524 -0
  32. package/src/context.js +537 -0
  33. package/src/credentials.js +123 -0
  34. package/src/doctor.js +131 -0
  35. package/src/errors.js +96 -0
  36. package/src/extension-runner.mjs +36 -0
  37. package/src/extensions.js +426 -0
  38. package/src/generation-ui.js +53 -0
  39. package/src/git.js +458 -0
  40. package/src/main.js +768 -0
  41. package/src/metrics.js +375 -0
  42. package/src/output.js +87 -0
  43. package/src/policy-command.js +172 -0
  44. package/src/policy.js +421 -0
  45. package/src/preset-command.js +91 -0
  46. package/src/provider-presets.js +361 -0
  47. package/src/providers.js +306 -0
  48. package/src/setup.js +268 -0
  49. package/src/split-checkpoint.js +252 -0
  50. package/src/split-hunks.js +263 -0
  51. package/src/split-plan.js +339 -0
  52. package/src/split.js +2285 -0
  53. package/src/team-policy.js +95 -0
  54. package/src/trust.js +31 -0
  55. package/src/ui.js +488 -0
  56. package/src/utils.js +165 -0
  57. 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
+ }