@hifullmoon/aicommit 2.4.0 → 2.5.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 +7 -1
- package/CHANGELOG.md +20 -1
- package/README.md +27 -18
- package/README.zh-CN.md +27 -18
- package/docs/distribution.md +1 -1
- package/docs/large-change-implementation-plan.md +14 -14
- package/docs/privacy.md +2 -2
- package/docs/troubleshooting.md +7 -3
- package/package.json +1 -1
- package/src/analysis-budget.js +17 -7
- package/src/analysis-cache.js +246 -0
- package/src/change-analysis.js +302 -81
- package/src/cli.js +15 -0
- package/src/completion.js +4 -1
- package/src/config.js +30 -2
- package/src/local-analysis.js +46 -16
- package/src/main.js +27 -2
- package/src/split.js +114 -15
package/src/change-analysis.js
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
isDeepAnalysis,
|
|
13
13
|
isGeneratedFile,
|
|
14
14
|
localOverview,
|
|
15
|
-
|
|
15
|
+
localPlanBatches,
|
|
16
16
|
} from './local-analysis.js';
|
|
17
17
|
|
|
18
18
|
function gitQuote(path) {
|
|
@@ -230,16 +230,17 @@ const ANALYSIS_SYSTEM =
|
|
|
230
230
|
export function analysisConfig(config) {
|
|
231
231
|
if (config.analysisBudget) return config;
|
|
232
232
|
const settings = { ...DEFAULT_LARGE_CHANGE, ...config.largeChange };
|
|
233
|
+
settings.cache = { ...DEFAULT_LARGE_CHANGE.cache, ...config.largeChange?.cache };
|
|
233
234
|
const model = getProviderAdapter(config).model;
|
|
234
|
-
const output =
|
|
235
|
-
config.
|
|
236
|
-
|
|
237
|
-
|
|
235
|
+
const output =
|
|
236
|
+
config.reasoning?.mode === 'off'
|
|
237
|
+
? config.maxTokens || 1024
|
|
238
|
+
: Math.max(config.maxTokens || 1024, config.reasoning?.maxTokens || 4096);
|
|
238
239
|
settings.chunkInputTokens = Math.min(
|
|
239
240
|
settings.chunkInputTokens,
|
|
240
241
|
Math.max(256, model.contextWindow - output - 1024),
|
|
241
242
|
);
|
|
242
|
-
return { ...config, analysisBudget: createAnalysisBudget(settings) };
|
|
243
|
+
return { ...config, largeChange: settings, analysisBudget: createAnalysisBudget(settings) };
|
|
243
244
|
}
|
|
244
245
|
|
|
245
246
|
export function packItems(items, maxChars, maxItems = 24) {
|
|
@@ -260,7 +261,64 @@ export function packItems(items, maxChars, maxItems = 24) {
|
|
|
260
261
|
return batches;
|
|
261
262
|
}
|
|
262
263
|
|
|
263
|
-
|
|
264
|
+
function parseAnalysisJson(raw) {
|
|
265
|
+
const text = String(raw || '')
|
|
266
|
+
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
|
267
|
+
.trim();
|
|
268
|
+
const fence = text.match(/^```[a-zA-Z]*\s*\n([\s\S]*?)\n?```$/);
|
|
269
|
+
const candidates = fence ? [fence[1].trim(), text] : [text];
|
|
270
|
+
|
|
271
|
+
for (const candidate of candidates) {
|
|
272
|
+
try {
|
|
273
|
+
const parsed = JSON.parse(candidate);
|
|
274
|
+
if (Array.isArray(parsed)) return parsed;
|
|
275
|
+
} catch {
|
|
276
|
+
// Fall through to the balanced-array extractor below. Some providers
|
|
277
|
+
// wrap otherwise valid JSON in a short explanation or an outer object.
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (
|
|
281
|
+
let start = candidate.indexOf('[');
|
|
282
|
+
start !== -1;
|
|
283
|
+
start = candidate.indexOf('[', start + 1)
|
|
284
|
+
) {
|
|
285
|
+
let depth = 0;
|
|
286
|
+
let quoted = false;
|
|
287
|
+
let escaped = false;
|
|
288
|
+
for (let index = start; index < candidate.length; index++) {
|
|
289
|
+
const char = candidate[index];
|
|
290
|
+
if (quoted) {
|
|
291
|
+
if (escaped) escaped = false;
|
|
292
|
+
else if (char === '\\') escaped = true;
|
|
293
|
+
else if (char === '"') quoted = false;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (char === '"') {
|
|
297
|
+
quoted = true;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (char === '[') depth++;
|
|
301
|
+
if (char !== ']') continue;
|
|
302
|
+
depth--;
|
|
303
|
+
if (depth !== 0) continue;
|
|
304
|
+
try {
|
|
305
|
+
const parsed = JSON.parse(candidate.slice(start, index + 1));
|
|
306
|
+
if (Array.isArray(parsed)) return parsed;
|
|
307
|
+
} catch {
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
throw new SyntaxError('Response contains no complete JSON array.');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function jsonCall(config, instruction, items, validate = null) {
|
|
317
|
+
const parseAndValidate = (raw) => {
|
|
318
|
+
const parsed = parseAnalysisJson(raw);
|
|
319
|
+
validate?.(parsed);
|
|
320
|
+
return parsed;
|
|
321
|
+
};
|
|
264
322
|
const result = await getResponseText(
|
|
265
323
|
config,
|
|
266
324
|
[
|
|
@@ -269,13 +327,25 @@ async function jsonCall(config, instruction, items) {
|
|
|
269
327
|
],
|
|
270
328
|
0,
|
|
271
329
|
Math.min(2048, config.maxTokens || 1024),
|
|
272
|
-
'Return the complete requested JSON, preserving
|
|
330
|
+
'Return the complete requested JSON array, preserving every required input ID exactly once. ' +
|
|
331
|
+
`Required IDs: ${JSON.stringify(items.map((item) => item.id))}`,
|
|
273
332
|
null,
|
|
333
|
+
(response) => {
|
|
334
|
+
try {
|
|
335
|
+
parseAndValidate(response);
|
|
336
|
+
return true;
|
|
337
|
+
} catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
},
|
|
274
341
|
);
|
|
275
342
|
try {
|
|
276
|
-
return
|
|
277
|
-
} catch {
|
|
278
|
-
|
|
343
|
+
return parseAndValidate(result.text);
|
|
344
|
+
} catch (cause) {
|
|
345
|
+
if (cause?.category === ERROR_CATEGORIES.RESPONSE_FORMAT) throw cause;
|
|
346
|
+
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Large-change analysis returned invalid JSON.', {
|
|
347
|
+
cause,
|
|
348
|
+
});
|
|
279
349
|
}
|
|
280
350
|
}
|
|
281
351
|
|
|
@@ -303,71 +373,215 @@ export function validatePartition(groups, ids) {
|
|
|
303
373
|
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Analysis left input IDs unassigned.');
|
|
304
374
|
}
|
|
305
375
|
|
|
376
|
+
function fragmentCacheKey(config, input, protect, persistentCache) {
|
|
377
|
+
return persistentCache
|
|
378
|
+
? persistentCache.keyFor('fragment-analysis', input)
|
|
379
|
+
: createHash('sha256')
|
|
380
|
+
.update(
|
|
381
|
+
JSON.stringify({
|
|
382
|
+
version: 1,
|
|
383
|
+
input,
|
|
384
|
+
protect,
|
|
385
|
+
model: config.modelId,
|
|
386
|
+
endpoint: config.apiUrl,
|
|
387
|
+
context: config.repositoryContextText,
|
|
388
|
+
}),
|
|
389
|
+
)
|
|
390
|
+
.digest('hex');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function validateFragmentGroups(candidate, inputIds) {
|
|
394
|
+
validatePartition(candidate, inputIds);
|
|
395
|
+
if (candidate.some((group) => group.ids.length !== 1))
|
|
396
|
+
throw fail(
|
|
397
|
+
ERROR_CATEGORIES.RESPONSE_FORMAT,
|
|
398
|
+
'Fragment analysis combined unrelated source IDs.',
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function estimateInitialDeepCost(
|
|
403
|
+
config,
|
|
404
|
+
capture,
|
|
405
|
+
protect,
|
|
406
|
+
previews,
|
|
407
|
+
maxBatchTokens,
|
|
408
|
+
persistentCache,
|
|
409
|
+
) {
|
|
410
|
+
const batchSizes = [];
|
|
411
|
+
let batch = [];
|
|
412
|
+
let batchTokens = 0;
|
|
413
|
+
let chunks = 0;
|
|
414
|
+
let cachedChunks = 0;
|
|
415
|
+
let oversizedInput = false;
|
|
416
|
+
const fragmentsByFile = new Map();
|
|
417
|
+
const flush = () => {
|
|
418
|
+
if (!batch.length) return;
|
|
419
|
+
chunks++;
|
|
420
|
+
const inputIds = batch.map((item) => item.id);
|
|
421
|
+
const cacheKey = fragmentCacheKey(config, batch, protect, persistentCache);
|
|
422
|
+
const cached =
|
|
423
|
+
capture.analysisCache?.get(cacheKey) ||
|
|
424
|
+
persistentCache?.read(cacheKey, inputIds, (candidate) =>
|
|
425
|
+
validateFragmentGroups(candidate, inputIds),
|
|
426
|
+
);
|
|
427
|
+
if (cached) cachedChunks++;
|
|
428
|
+
else batchSizes.push(batchTokens);
|
|
429
|
+
batch = [];
|
|
430
|
+
batchTokens = 0;
|
|
431
|
+
};
|
|
432
|
+
for (const unit of capture.units(protect, previews)) {
|
|
433
|
+
if (unit.metadataOnly) continue;
|
|
434
|
+
fragmentsByFile.set(unit.fileId, (fragmentsByFile.get(unit.fileId) || 0) + 1);
|
|
435
|
+
const size = estimateTokens(JSON.stringify(unit));
|
|
436
|
+
if (size + 768 > config.analysisBudget.limits.chunkInputTokens) oversizedInput = true;
|
|
437
|
+
if (batch.length && (batchTokens + size > maxBatchTokens || batch.length >= 16)) flush();
|
|
438
|
+
batch.push(unit);
|
|
439
|
+
batchTokens += size;
|
|
440
|
+
}
|
|
441
|
+
flush();
|
|
442
|
+
const output = Math.min(2048, config.maxTokens || 1024);
|
|
443
|
+
let reductionRequests = 0;
|
|
444
|
+
if (config.analysisTask === 'split') {
|
|
445
|
+
for (const fragments of fragmentsByFile.values()) {
|
|
446
|
+
let summaries = fragments;
|
|
447
|
+
while (summaries * 160 > 2000) {
|
|
448
|
+
const batches = Math.ceil(summaries / 24);
|
|
449
|
+
reductionRequests += batches;
|
|
450
|
+
summaries = batches;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
let planningRequests = 1;
|
|
455
|
+
if (config.analysisTask === 'split') {
|
|
456
|
+
planningRequests = 0;
|
|
457
|
+
let candidates = Math.max(1, capture.manifest.length);
|
|
458
|
+
for (let level = 0; level < 8; level++) {
|
|
459
|
+
const batches = Math.ceil(candidates / (config.splitMaxPlanFiles || 100));
|
|
460
|
+
planningRequests += batches;
|
|
461
|
+
if (batches === 1) break;
|
|
462
|
+
candidates = batches;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const downstreamRequests = reductionRequests + planningRequests;
|
|
466
|
+
const downstreamReserve =
|
|
467
|
+
downstreamRequests * (config.analysisBudget.limits.chunkInputTokens + output);
|
|
468
|
+
const estimatedTokens =
|
|
469
|
+
batchSizes.reduce(
|
|
470
|
+
(total, input) =>
|
|
471
|
+
total + Math.min(config.analysisBudget.limits.chunkInputTokens, input + 768) + output,
|
|
472
|
+
0,
|
|
473
|
+
) + downstreamReserve;
|
|
474
|
+
return {
|
|
475
|
+
chunks,
|
|
476
|
+
cachedChunks,
|
|
477
|
+
requestedChunks: batchSizes.length,
|
|
478
|
+
estimatedTokens,
|
|
479
|
+
oversizedInput,
|
|
480
|
+
reductionRequests,
|
|
481
|
+
planningRequests,
|
|
482
|
+
downstreamReserve,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
306
486
|
export async function analyzeChanges(
|
|
307
487
|
config,
|
|
308
488
|
capture,
|
|
309
489
|
protect = true,
|
|
310
490
|
previews = null,
|
|
311
491
|
onProgress = null,
|
|
492
|
+
persistentCache = null,
|
|
312
493
|
) {
|
|
313
494
|
if (!isDeepAnalysis(config)) return analyzeLocally(config, capture, protect, previews);
|
|
314
495
|
const budget = config.analysisBudget;
|
|
496
|
+
const maxBatchTokens = Math.min(
|
|
497
|
+
estimateTokens('x'.repeat(config.maxDiffChars || 30000)),
|
|
498
|
+
Math.floor(budget.limits.chunkInputTokens * 0.65),
|
|
499
|
+
);
|
|
500
|
+
// chunkInputTokens is a token budget, while String#length counts UTF-16 code
|
|
501
|
+
// units. Track the same conservative UTF-8 estimate used at dispatch time so
|
|
502
|
+
// ASCII-heavy repositories do not produce roughly twice as many requests and
|
|
503
|
+
// multibyte input still stays within the configured ceiling.
|
|
315
504
|
const maxChars = Math.min(
|
|
316
505
|
config.maxDiffChars || 30000,
|
|
317
506
|
Math.floor(budget.limits.chunkInputTokens * 0.65),
|
|
318
507
|
);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
508
|
+
const estimate = estimateInitialDeepCost(
|
|
509
|
+
config,
|
|
510
|
+
capture,
|
|
511
|
+
protect,
|
|
512
|
+
previews,
|
|
513
|
+
maxBatchTokens,
|
|
514
|
+
persistentCache,
|
|
322
515
|
);
|
|
516
|
+
if (estimate.oversizedInput || estimate.estimatedTokens > budget.limits.maxTotalTokens) {
|
|
517
|
+
const fallbackConfig = {
|
|
518
|
+
...config,
|
|
519
|
+
largeChange: { ...config.largeChange, strategy: 'auto' },
|
|
520
|
+
};
|
|
521
|
+
const local = analyzeLocally(fallbackConfig, capture, protect, previews);
|
|
522
|
+
local.coverage = {
|
|
523
|
+
...local.coverage,
|
|
524
|
+
degradedFrom: 'deep',
|
|
525
|
+
fallbackReason: estimate.oversizedInput ? 'preflight_input' : 'preflight_tokens',
|
|
526
|
+
estimatedDeepChunks: estimate.chunks,
|
|
527
|
+
estimatedCachedChunks: estimate.cachedChunks,
|
|
528
|
+
estimatedRequestedChunks: estimate.requestedChunks,
|
|
529
|
+
estimatedReductionRequests: estimate.reductionRequests,
|
|
530
|
+
estimatedPlanningRequests: estimate.planningRequests,
|
|
531
|
+
estimatedDeepTokens: estimate.estimatedTokens,
|
|
532
|
+
};
|
|
533
|
+
return local;
|
|
534
|
+
}
|
|
535
|
+
budget.reserveFinal(estimate.downstreamReserve);
|
|
323
536
|
const fileFacts = new Map(capture.manifest.map((f) => [f.id, []]));
|
|
324
537
|
let batch = [];
|
|
325
|
-
let
|
|
538
|
+
let batchTokens = 0;
|
|
326
539
|
let done = 0;
|
|
327
540
|
let analyzed = 0;
|
|
541
|
+
let cachedChunks = 0;
|
|
542
|
+
let requestedChunks = 0;
|
|
328
543
|
const pending = [];
|
|
329
544
|
const cache = (capture.analysisCache ||= new Map());
|
|
330
545
|
async function flush() {
|
|
331
546
|
if (!batch.length) return;
|
|
332
547
|
const input = batch;
|
|
333
548
|
batch = [];
|
|
334
|
-
|
|
335
|
-
const cacheKey =
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
.digest('hex');
|
|
347
|
-
const groups =
|
|
348
|
-
cache.get(cacheKey) ||
|
|
349
|
-
(await jsonCall(
|
|
549
|
+
batchTokens = 0;
|
|
550
|
+
const cacheKey = fragmentCacheKey(config, input, protect, persistentCache);
|
|
551
|
+
const inputIds = input.map((x) => x.id);
|
|
552
|
+
const validateGroups = (candidate) => validateFragmentGroups(candidate, inputIds);
|
|
553
|
+
let groups = cache.get(cacheKey);
|
|
554
|
+
let cacheHit = Boolean(groups);
|
|
555
|
+
if (!groups && persistentCache) {
|
|
556
|
+
groups = persistentCache.read(cacheKey, inputIds, validateGroups);
|
|
557
|
+
cacheHit = Boolean(groups);
|
|
558
|
+
}
|
|
559
|
+
if (!groups) {
|
|
560
|
+
groups = await jsonCall(
|
|
350
561
|
config,
|
|
351
562
|
'Return one entry per input ID: [{"ids":["input ID"],"summary":"concise factual changes and uncertainties"}]. Each input ID must appear exactly once. Never combine different IDs. Summaries must be at most 160 characters.',
|
|
352
563
|
input,
|
|
353
|
-
|
|
354
|
-
validatePartition(
|
|
355
|
-
groups,
|
|
356
|
-
input.map((x) => x.id),
|
|
357
|
-
);
|
|
358
|
-
const byId = new Map(input.map((x) => [x.id, x.fileId]));
|
|
359
|
-
if (groups.some((group) => group.ids.length !== 1))
|
|
360
|
-
throw fail(
|
|
361
|
-
ERROR_CATEGORIES.RESPONSE_FORMAT,
|
|
362
|
-
'Fragment analysis combined unrelated source IDs.',
|
|
564
|
+
validateGroups,
|
|
363
565
|
);
|
|
566
|
+
requestedChunks++;
|
|
567
|
+
persistentCache?.write(cacheKey, inputIds, groups);
|
|
568
|
+
} else {
|
|
569
|
+
cachedChunks++;
|
|
570
|
+
}
|
|
571
|
+
const byId = new Map(input.map((x) => [x.id, x.fileId]));
|
|
364
572
|
cache.set(cacheKey, groups);
|
|
365
573
|
for (const group of groups) {
|
|
366
574
|
for (const id of new Set(group.ids.map((x) => byId.get(x))))
|
|
367
575
|
fileFacts.get(id).push(group.summary);
|
|
368
576
|
}
|
|
369
577
|
analyzed += input.filter((x) => !x.metadataOnly).length;
|
|
370
|
-
onProgress?.({
|
|
578
|
+
onProgress?.({
|
|
579
|
+
completedChunks: ++done,
|
|
580
|
+
analyzedFragments: analyzed,
|
|
581
|
+
cachedChunks,
|
|
582
|
+
requestedChunks,
|
|
583
|
+
cacheHit,
|
|
584
|
+
});
|
|
371
585
|
}
|
|
372
586
|
async function schedule() {
|
|
373
587
|
pending.push(
|
|
@@ -387,10 +601,11 @@ export async function analyzeChanges(
|
|
|
387
601
|
fileFacts.get(unit.fileId).push(unit.text);
|
|
388
602
|
continue;
|
|
389
603
|
}
|
|
390
|
-
const size = JSON.stringify(unit)
|
|
391
|
-
if (batch.length && (
|
|
604
|
+
const size = estimateTokens(JSON.stringify(unit));
|
|
605
|
+
if (batch.length && (batchTokens + size > maxBatchTokens || batch.length >= 16))
|
|
606
|
+
await schedule();
|
|
392
607
|
batch.push(unit);
|
|
393
|
-
|
|
608
|
+
batchTokens += size;
|
|
394
609
|
}
|
|
395
610
|
await schedule();
|
|
396
611
|
for (const err of await Promise.all(pending)) if (err) throw err;
|
|
@@ -411,17 +626,17 @@ export async function analyzeChanges(
|
|
|
411
626
|
summaries.map((summary, i) => ({ id: `S${i}`, summary })),
|
|
412
627
|
maxChars,
|
|
413
628
|
)) {
|
|
629
|
+
const ids = group.map((x) => x.id);
|
|
414
630
|
const reduced = await jsonCall(
|
|
415
631
|
config,
|
|
416
632
|
'Return [{"ids":[all input IDs],"summary":"one factual summary, at most 400 characters"}]. Preserve important behavior and uncertainty.',
|
|
417
633
|
group,
|
|
634
|
+
(candidate) => {
|
|
635
|
+
validatePartition(candidate, ids);
|
|
636
|
+
if (candidate.length !== 1)
|
|
637
|
+
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Expected one reduced summary.');
|
|
638
|
+
},
|
|
418
639
|
);
|
|
419
|
-
validatePartition(
|
|
420
|
-
reduced,
|
|
421
|
-
group.map((x) => x.id),
|
|
422
|
-
);
|
|
423
|
-
if (reduced.length !== 1)
|
|
424
|
-
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Expected one reduced summary.');
|
|
425
640
|
next.push(reduced[0].summary);
|
|
426
641
|
}
|
|
427
642
|
summaries = next;
|
|
@@ -441,15 +656,22 @@ export async function analyzeChanges(
|
|
|
441
656
|
metadataOnlyFiles: capture.manifest.filter((f) => f.metadataOnly).length,
|
|
442
657
|
failedFiles: 0,
|
|
443
658
|
completedChunks: done,
|
|
659
|
+
cachedChunks,
|
|
660
|
+
requestedChunks,
|
|
444
661
|
},
|
|
445
662
|
};
|
|
446
663
|
} catch (err) {
|
|
664
|
+
const exhausted = err.data?.analysis?.exhausted || (!budget.remainingMs() ? 'time' : undefined);
|
|
447
665
|
err.data = {
|
|
448
666
|
...err.data,
|
|
449
667
|
analysis: {
|
|
450
668
|
...budget.snapshot(),
|
|
669
|
+
...err.data?.analysis,
|
|
670
|
+
...(exhausted ? { exhausted } : {}),
|
|
451
671
|
totalFiles: capture.manifest.length,
|
|
452
672
|
completedChunks: done,
|
|
673
|
+
cachedChunks,
|
|
674
|
+
requestedChunks,
|
|
453
675
|
complete: false,
|
|
454
676
|
},
|
|
455
677
|
};
|
|
@@ -472,17 +694,17 @@ export async function summarizeChanges(config, facts) {
|
|
|
472
694
|
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Change summary did not converge.');
|
|
473
695
|
const next = [];
|
|
474
696
|
for (const batch of packItems(items, cap)) {
|
|
697
|
+
const ids = batch.map((x) => x.id);
|
|
475
698
|
const groups = await jsonCall(
|
|
476
699
|
config,
|
|
477
700
|
'Return [{"ids":[all input IDs],"summary":"one summary of supported behavior changes and uncertainty, at most 400 characters"}].',
|
|
478
701
|
batch,
|
|
702
|
+
(candidate) => {
|
|
703
|
+
validatePartition(candidate, ids);
|
|
704
|
+
if (candidate.length !== 1)
|
|
705
|
+
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Expected one reduced summary.');
|
|
706
|
+
},
|
|
479
707
|
);
|
|
480
|
-
validatePartition(
|
|
481
|
-
groups,
|
|
482
|
-
batch.map((x) => x.id),
|
|
483
|
-
);
|
|
484
|
-
if (groups.length !== 1)
|
|
485
|
-
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Expected one reduced summary.');
|
|
486
708
|
next.push({ id: `L${level}N${next.length}`, summary: groups[0].summary });
|
|
487
709
|
}
|
|
488
710
|
items = next;
|
|
@@ -492,6 +714,7 @@ export async function summarizeChanges(config, facts) {
|
|
|
492
714
|
|
|
493
715
|
export async function planAnalyzedChanges(config, facts, coverage = null) {
|
|
494
716
|
let candidates = facts;
|
|
717
|
+
const deep = isDeepAnalysis(config) && coverage?.strategy !== 'auto';
|
|
495
718
|
const policy = normalizeCommitPolicy(config.commitPolicy, config.language);
|
|
496
719
|
const cap = Math.min(
|
|
497
720
|
config.splitMaxDiffChars || 16000,
|
|
@@ -499,34 +722,35 @@ export async function planAnalyzedChanges(config, facts, coverage = null) {
|
|
|
499
722
|
);
|
|
500
723
|
for (let level = 0; level < 8; level++) {
|
|
501
724
|
const byId = new Map(candidates.map((x) => [x.id, x]));
|
|
502
|
-
const batches =
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
725
|
+
const batches =
|
|
726
|
+
!deep && level === 0
|
|
727
|
+
? localPlanBatches(config, candidates, cap)
|
|
728
|
+
: packItems(
|
|
729
|
+
candidates.map(({ id, path, summary }) => ({ id, path, summary })),
|
|
730
|
+
cap,
|
|
731
|
+
config.splitMaxPlanFiles || 100,
|
|
732
|
+
);
|
|
733
|
+
if (!deep && level === 0 && coverage) {
|
|
734
|
+
coverage.sampledFiles = batches.flat().filter((item) => item.representativeExcerpt).length;
|
|
511
735
|
coverage.metadataOnlyFiles = coverage.totalFiles - coverage.sampledFiles;
|
|
512
736
|
}
|
|
513
737
|
const next = [];
|
|
514
738
|
for (const batch of batches) {
|
|
739
|
+
const ids = batch.map((x) => x.id);
|
|
515
740
|
const groups = await jsonCall(
|
|
516
741
|
config,
|
|
517
742
|
`Each commit message must follow this policy: ${JSON.stringify(policy)}.\nGroup related changes into logical commits, including related implementation and tests across directories. Return [{"ids":[input IDs],"summary":"factual combined change summary","subject":"commit subject","body":"optional commit body"}]. Assign every input ID exactly once. Do not merge unrelated changes just to reduce group count.`,
|
|
518
743
|
batch,
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
744
|
+
(candidate) => {
|
|
745
|
+
validatePartition(candidate, ids);
|
|
746
|
+
if (candidate.some((group) => typeof group.subject !== 'string' || !group.subject.trim()))
|
|
747
|
+
throw fail(
|
|
748
|
+
ERROR_CATEGORIES.RESPONSE_FORMAT,
|
|
749
|
+
'Analysis plan is missing a commit subject.',
|
|
750
|
+
);
|
|
751
|
+
},
|
|
523
752
|
);
|
|
524
753
|
for (const group of groups) {
|
|
525
|
-
if (typeof group.subject !== 'string' || !group.subject.trim())
|
|
526
|
-
throw fail(
|
|
527
|
-
ERROR_CATEGORIES.RESPONSE_FORMAT,
|
|
528
|
-
'Analysis plan is missing a commit subject.',
|
|
529
|
-
);
|
|
530
754
|
next.push({
|
|
531
755
|
...group,
|
|
532
756
|
id: `L${level}G${next.length}`,
|
|
@@ -536,14 +760,11 @@ export async function planAnalyzedChanges(config, facts, coverage = null) {
|
|
|
536
760
|
}
|
|
537
761
|
if (batches.length === 1)
|
|
538
762
|
return next.map(({ subject, body, files }) => ({ subject, body, files }));
|
|
539
|
-
if (next.length >= candidates.length)
|
|
540
|
-
throw fail(
|
|
541
|
-
ERROR_CATEGORIES.PROVIDER,
|
|
542
|
-
'Too many independent change groups for a complete global plan. Stage a smaller logical change or increase planning limits.',
|
|
543
|
-
);
|
|
544
763
|
candidates = next;
|
|
545
764
|
}
|
|
546
|
-
throw fail(ERROR_CATEGORIES.PROVIDER, 'Split planning exceeded the maximum summary depth.'
|
|
765
|
+
throw fail(ERROR_CATEGORIES.PROVIDER, 'Split planning exceeded the maximum summary depth.', {
|
|
766
|
+
data: { fallbackPlan: true },
|
|
767
|
+
});
|
|
547
768
|
}
|
|
548
769
|
|
|
549
770
|
export function needsAnalysis(capture, config, split = false) {
|
package/src/cli.js
CHANGED
|
@@ -52,6 +52,7 @@ function showHelp() {
|
|
|
52
52
|
--no-reasoning Explicitly disable reasoning when supported
|
|
53
53
|
--dry-run Generate and review without creating commits
|
|
54
54
|
-y, --yes Non-interactive: accept the generated message/plan
|
|
55
|
+
--allow-single-fallback Let non-interactive split commit one complete group if planning exhausts its budget
|
|
55
56
|
--output=<text|json> Output mode; JSON requires --yes for commit flows
|
|
56
57
|
--debug Print debug info (parsed args, final config, etc.)
|
|
57
58
|
|
|
@@ -93,6 +94,7 @@ function parsedDefaults(overrides = {}) {
|
|
|
93
94
|
splitPlanFile: null,
|
|
94
95
|
dryRun: false,
|
|
95
96
|
yes: false,
|
|
97
|
+
allowSingleFallback: false,
|
|
96
98
|
setup: false,
|
|
97
99
|
update: false,
|
|
98
100
|
doctor: false,
|
|
@@ -201,6 +203,7 @@ export function parseArgs(args = process.argv.slice(2)) {
|
|
|
201
203
|
let splitScopeOption = false;
|
|
202
204
|
let dryRun = false;
|
|
203
205
|
let yes = false;
|
|
206
|
+
let allowSingleFallback = false;
|
|
204
207
|
const setup = false;
|
|
205
208
|
|
|
206
209
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -290,6 +293,11 @@ export function parseArgs(args = process.argv.slice(2)) {
|
|
|
290
293
|
continue;
|
|
291
294
|
}
|
|
292
295
|
|
|
296
|
+
if (arg === '--allow-single-fallback') {
|
|
297
|
+
allowSingleFallback = true;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
293
301
|
if (arg === '--reasoning') {
|
|
294
302
|
cliReasoning = takeValue(args, i, arg, 'low|medium|high|xhigh|max');
|
|
295
303
|
i++;
|
|
@@ -428,6 +436,12 @@ export function parseArgs(args = process.argv.slice(2)) {
|
|
|
428
436
|
if (splitPlanFile && !['plan', 'apply'].includes(splitCommand)) {
|
|
429
437
|
throw fail(ERROR_CATEGORIES.CONFIG, '--file is only valid with split plan or split apply.');
|
|
430
438
|
}
|
|
439
|
+
if (allowSingleFallback && (splitCommand !== 'run' || !yes || dryRun)) {
|
|
440
|
+
throw fail(
|
|
441
|
+
ERROR_CATEGORIES.CONFIG,
|
|
442
|
+
'--allow-single-fallback requires non-dry-run "aicommit split run --yes".',
|
|
443
|
+
);
|
|
444
|
+
}
|
|
431
445
|
if (
|
|
432
446
|
configAction &&
|
|
433
447
|
(cliLang || cliReasoning || split || splitCommand || splitPlanFile || dryRun || yes)
|
|
@@ -497,6 +511,7 @@ export function parseArgs(args = process.argv.slice(2)) {
|
|
|
497
511
|
splitPlanFile,
|
|
498
512
|
dryRun,
|
|
499
513
|
yes,
|
|
514
|
+
allowSingleFallback,
|
|
500
515
|
setup,
|
|
501
516
|
update,
|
|
502
517
|
doctor,
|
package/src/completion.js
CHANGED
|
@@ -16,6 +16,7 @@ const TOP_LEVEL = [
|
|
|
16
16
|
'--no-reasoning',
|
|
17
17
|
'--dry-run',
|
|
18
18
|
'--yes',
|
|
19
|
+
'--allow-single-fallback',
|
|
19
20
|
'--output',
|
|
20
21
|
'--debug',
|
|
21
22
|
];
|
|
@@ -32,7 +33,7 @@ _aicommit() {
|
|
|
32
33
|
config) COMPREPLY=( $(compgen -W "show validate path --provider --model --output --debug" -- "$cur") ); return ;;
|
|
33
34
|
policy) COMPREPLY=( $(compgen -W "template check --file --range --output --debug" -- "$cur") ); return ;;
|
|
34
35
|
completion) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ); return ;;
|
|
35
|
-
split) COMPREPLY=( $(compgen -W "run plan apply resume abort --scope --file --yes --output --debug" -- "$cur") ); return ;;
|
|
36
|
+
split) COMPREPLY=( $(compgen -W "run plan apply resume abort --scope --file --yes --allow-single-fallback --output --debug" -- "$cur") ); return ;;
|
|
36
37
|
esac
|
|
37
38
|
case "$prev" in
|
|
38
39
|
--lang|-l) COMPREPLY=( $(compgen -W "zh en" -- "$cur") ); return ;;
|
|
@@ -72,6 +73,7 @@ _aicommit() {
|
|
|
72
73
|
'--no-reasoning[disable reasoning]'
|
|
73
74
|
'--dry-run[review without committing]'
|
|
74
75
|
'--yes[accept without prompts]'
|
|
76
|
+
'--allow-single-fallback[allow non-interactive split to commit one conservative group]'
|
|
75
77
|
'--output=[output mode]:output:(text json)'
|
|
76
78
|
'--debug[show debug details]'
|
|
77
79
|
)
|
|
@@ -118,6 +120,7 @@ complete -c aicommit -l reasoning -x -a 'low medium high xhigh max' -d 'Reasonin
|
|
|
118
120
|
complete -c aicommit -l no-reasoning -d 'Disable reasoning'
|
|
119
121
|
complete -c aicommit -l dry-run -d 'Review without committing'
|
|
120
122
|
complete -c aicommit -s y -l yes -d 'Accept without prompts'
|
|
123
|
+
complete -c aicommit -l allow-single-fallback -d 'Allow non-interactive split to commit one conservative group'
|
|
121
124
|
complete -c aicommit -l output -x -a 'text json' -d 'Output mode'
|
|
122
125
|
complete -c aicommit -l debug -d 'Show debug details'
|
|
123
126
|
`;
|
package/src/config.js
CHANGED
|
@@ -420,7 +420,11 @@ function assertUrl(config, key) {
|
|
|
420
420
|
}
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
-
function assertNumber(
|
|
423
|
+
function assertNumber(
|
|
424
|
+
config,
|
|
425
|
+
key,
|
|
426
|
+
{ integer = false, min = -Infinity, max = Infinity, label = key } = {},
|
|
427
|
+
) {
|
|
424
428
|
const value = config[key];
|
|
425
429
|
const ok =
|
|
426
430
|
typeof value === 'number' &&
|
|
@@ -431,7 +435,7 @@ function assertNumber(config, key, { integer = false, min = -Infinity, max = Inf
|
|
|
431
435
|
if (!ok) {
|
|
432
436
|
const kind = integer ? 'integer' : 'number';
|
|
433
437
|
const range = Number.isFinite(max) ? ` between ${min} and ${max}` : ` >= ${min}`;
|
|
434
|
-
throw new Error(`Invalid config "${
|
|
438
|
+
throw new Error(`Invalid config "${label}": expected a ${kind}${range}.`);
|
|
435
439
|
}
|
|
436
440
|
}
|
|
437
441
|
|
|
@@ -449,6 +453,30 @@ export function validateConfig(config) {
|
|
|
449
453
|
assertNumber(settings, 'concurrency', { integer: true, min: 1, max: 4 });
|
|
450
454
|
if (settings.maxTotalTokens < settings.chunkInputTokens)
|
|
451
455
|
throw new Error('largeChange.maxTotalTokens must cover one input request.');
|
|
456
|
+
if (!object(settings.cache))
|
|
457
|
+
throw new Error('Invalid config "largeChange.cache": expected an object.');
|
|
458
|
+
assertKnownKeys(
|
|
459
|
+
settings.cache,
|
|
460
|
+
new Set(Object.keys(DEFAULT_LARGE_CHANGE.cache)),
|
|
461
|
+
'largeChange.cache',
|
|
462
|
+
);
|
|
463
|
+
const cache = { ...DEFAULT_LARGE_CHANGE.cache, ...settings.cache };
|
|
464
|
+
for (const key of ['enabled', 'allowUnprotected']) {
|
|
465
|
+
if (typeof cache[key] !== 'boolean')
|
|
466
|
+
throw new Error(`Invalid config "largeChange.cache.${key}": expected a boolean.`);
|
|
467
|
+
}
|
|
468
|
+
assertNumber(cache, 'ttlMs', {
|
|
469
|
+
integer: true,
|
|
470
|
+
min: 60_000,
|
|
471
|
+
max: 7 * 24 * 60 * 60 * 1000,
|
|
472
|
+
label: 'largeChange.cache.ttlMs',
|
|
473
|
+
});
|
|
474
|
+
assertNumber(cache, 'maxBytes', {
|
|
475
|
+
integer: true,
|
|
476
|
+
min: 1024 * 1024,
|
|
477
|
+
max: 256 * 1024 * 1024,
|
|
478
|
+
label: 'largeChange.cache.maxBytes',
|
|
479
|
+
});
|
|
452
480
|
}
|
|
453
481
|
assertUrl(config, 'apiUrl');
|
|
454
482
|
assertString(config, 'modelId');
|