@hifullmoon/aicommit 2.4.1 → 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 +13 -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 +206 -54
- 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) {
|
|
@@ -372,63 +373,201 @@ export function validatePartition(groups, ids) {
|
|
|
372
373
|
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Analysis left input IDs unassigned.');
|
|
373
374
|
}
|
|
374
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
|
+
|
|
375
486
|
export async function analyzeChanges(
|
|
376
487
|
config,
|
|
377
488
|
capture,
|
|
378
489
|
protect = true,
|
|
379
490
|
previews = null,
|
|
380
491
|
onProgress = null,
|
|
492
|
+
persistentCache = null,
|
|
381
493
|
) {
|
|
382
494
|
if (!isDeepAnalysis(config)) return analyzeLocally(config, capture, protect, previews);
|
|
383
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.
|
|
384
504
|
const maxChars = Math.min(
|
|
385
505
|
config.maxDiffChars || 30000,
|
|
386
506
|
Math.floor(budget.limits.chunkInputTokens * 0.65),
|
|
387
507
|
);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
508
|
+
const estimate = estimateInitialDeepCost(
|
|
509
|
+
config,
|
|
510
|
+
capture,
|
|
511
|
+
protect,
|
|
512
|
+
previews,
|
|
513
|
+
maxBatchTokens,
|
|
514
|
+
persistentCache,
|
|
391
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);
|
|
392
536
|
const fileFacts = new Map(capture.manifest.map((f) => [f.id, []]));
|
|
393
537
|
let batch = [];
|
|
394
|
-
let
|
|
538
|
+
let batchTokens = 0;
|
|
395
539
|
let done = 0;
|
|
396
540
|
let analyzed = 0;
|
|
541
|
+
let cachedChunks = 0;
|
|
542
|
+
let requestedChunks = 0;
|
|
397
543
|
const pending = [];
|
|
398
544
|
const cache = (capture.analysisCache ||= new Map());
|
|
399
545
|
async function flush() {
|
|
400
546
|
if (!batch.length) return;
|
|
401
547
|
const input = batch;
|
|
402
548
|
batch = [];
|
|
403
|
-
|
|
404
|
-
const cacheKey =
|
|
405
|
-
.update(
|
|
406
|
-
JSON.stringify({
|
|
407
|
-
version: 1,
|
|
408
|
-
input,
|
|
409
|
-
protect,
|
|
410
|
-
model: config.modelId,
|
|
411
|
-
endpoint: config.apiUrl,
|
|
412
|
-
context: config.repositoryContextText,
|
|
413
|
-
}),
|
|
414
|
-
)
|
|
415
|
-
.digest('hex');
|
|
549
|
+
batchTokens = 0;
|
|
550
|
+
const cacheKey = fragmentCacheKey(config, input, protect, persistentCache);
|
|
416
551
|
const inputIds = input.map((x) => x.id);
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
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(
|
|
420
561
|
config,
|
|
421
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.',
|
|
422
563
|
input,
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
},
|
|
431
|
-
));
|
|
564
|
+
validateGroups,
|
|
565
|
+
);
|
|
566
|
+
requestedChunks++;
|
|
567
|
+
persistentCache?.write(cacheKey, inputIds, groups);
|
|
568
|
+
} else {
|
|
569
|
+
cachedChunks++;
|
|
570
|
+
}
|
|
432
571
|
const byId = new Map(input.map((x) => [x.id, x.fileId]));
|
|
433
572
|
cache.set(cacheKey, groups);
|
|
434
573
|
for (const group of groups) {
|
|
@@ -436,7 +575,13 @@ export async function analyzeChanges(
|
|
|
436
575
|
fileFacts.get(id).push(group.summary);
|
|
437
576
|
}
|
|
438
577
|
analyzed += input.filter((x) => !x.metadataOnly).length;
|
|
439
|
-
onProgress?.({
|
|
578
|
+
onProgress?.({
|
|
579
|
+
completedChunks: ++done,
|
|
580
|
+
analyzedFragments: analyzed,
|
|
581
|
+
cachedChunks,
|
|
582
|
+
requestedChunks,
|
|
583
|
+
cacheHit,
|
|
584
|
+
});
|
|
440
585
|
}
|
|
441
586
|
async function schedule() {
|
|
442
587
|
pending.push(
|
|
@@ -456,10 +601,11 @@ export async function analyzeChanges(
|
|
|
456
601
|
fileFacts.get(unit.fileId).push(unit.text);
|
|
457
602
|
continue;
|
|
458
603
|
}
|
|
459
|
-
const size = JSON.stringify(unit)
|
|
460
|
-
if (batch.length && (
|
|
604
|
+
const size = estimateTokens(JSON.stringify(unit));
|
|
605
|
+
if (batch.length && (batchTokens + size > maxBatchTokens || batch.length >= 16))
|
|
606
|
+
await schedule();
|
|
461
607
|
batch.push(unit);
|
|
462
|
-
|
|
608
|
+
batchTokens += size;
|
|
463
609
|
}
|
|
464
610
|
await schedule();
|
|
465
611
|
for (const err of await Promise.all(pending)) if (err) throw err;
|
|
@@ -510,15 +656,22 @@ export async function analyzeChanges(
|
|
|
510
656
|
metadataOnlyFiles: capture.manifest.filter((f) => f.metadataOnly).length,
|
|
511
657
|
failedFiles: 0,
|
|
512
658
|
completedChunks: done,
|
|
659
|
+
cachedChunks,
|
|
660
|
+
requestedChunks,
|
|
513
661
|
},
|
|
514
662
|
};
|
|
515
663
|
} catch (err) {
|
|
664
|
+
const exhausted = err.data?.analysis?.exhausted || (!budget.remainingMs() ? 'time' : undefined);
|
|
516
665
|
err.data = {
|
|
517
666
|
...err.data,
|
|
518
667
|
analysis: {
|
|
519
668
|
...budget.snapshot(),
|
|
669
|
+
...err.data?.analysis,
|
|
670
|
+
...(exhausted ? { exhausted } : {}),
|
|
520
671
|
totalFiles: capture.manifest.length,
|
|
521
672
|
completedChunks: done,
|
|
673
|
+
cachedChunks,
|
|
674
|
+
requestedChunks,
|
|
522
675
|
complete: false,
|
|
523
676
|
},
|
|
524
677
|
};
|
|
@@ -561,6 +714,7 @@ export async function summarizeChanges(config, facts) {
|
|
|
561
714
|
|
|
562
715
|
export async function planAnalyzedChanges(config, facts, coverage = null) {
|
|
563
716
|
let candidates = facts;
|
|
717
|
+
const deep = isDeepAnalysis(config) && coverage?.strategy !== 'auto';
|
|
564
718
|
const policy = normalizeCommitPolicy(config.commitPolicy, config.language);
|
|
565
719
|
const cap = Math.min(
|
|
566
720
|
config.splitMaxDiffChars || 16000,
|
|
@@ -568,15 +722,16 @@ export async function planAnalyzedChanges(config, facts, coverage = null) {
|
|
|
568
722
|
);
|
|
569
723
|
for (let level = 0; level < 8; level++) {
|
|
570
724
|
const byId = new Map(candidates.map((x) => [x.id, x]));
|
|
571
|
-
const batches =
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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;
|
|
580
735
|
coverage.metadataOnlyFiles = coverage.totalFiles - coverage.sampledFiles;
|
|
581
736
|
}
|
|
582
737
|
const next = [];
|
|
@@ -605,14 +760,11 @@ export async function planAnalyzedChanges(config, facts, coverage = null) {
|
|
|
605
760
|
}
|
|
606
761
|
if (batches.length === 1)
|
|
607
762
|
return next.map(({ subject, body, files }) => ({ subject, body, files }));
|
|
608
|
-
if (next.length >= candidates.length)
|
|
609
|
-
throw fail(
|
|
610
|
-
ERROR_CATEGORIES.PROVIDER,
|
|
611
|
-
'Too many independent change groups for a complete global plan. Stage a smaller logical change or increase planning limits.',
|
|
612
|
-
);
|
|
613
763
|
candidates = next;
|
|
614
764
|
}
|
|
615
|
-
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
|
+
});
|
|
616
768
|
}
|
|
617
769
|
|
|
618
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');
|
package/src/local-analysis.js
CHANGED
|
@@ -245,27 +245,57 @@ export function analyzeLocally(config, capture, protect, previews) {
|
|
|
245
245
|
metadataOnlyFiles: capture.manifest.length - overview.sampledFiles,
|
|
246
246
|
failedFiles: 0,
|
|
247
247
|
completedChunks: 0,
|
|
248
|
+
cachedChunks: 0,
|
|
249
|
+
requestedChunks: 0,
|
|
248
250
|
},
|
|
249
251
|
};
|
|
250
252
|
}
|
|
251
253
|
|
|
252
|
-
export function
|
|
254
|
+
export function localPlanBatches(config, facts, cap) {
|
|
253
255
|
const error = () =>
|
|
254
|
-
fail(
|
|
256
|
+
fail(ERROR_CATEGORIES.CONFIG, 'A split candidate is too large for one planning request.', {
|
|
257
|
+
data: { fallbackPlan: true },
|
|
258
|
+
});
|
|
259
|
+
const maxItems = config.splitMaxPlanFiles || 100;
|
|
260
|
+
const batches = [];
|
|
261
|
+
let batch = [];
|
|
262
|
+
for (const fact of facts) {
|
|
263
|
+
const item = {
|
|
264
|
+
...summaryOf(fact, false),
|
|
265
|
+
summary: 'Local candidate only; verify grouping. Content not fully analyzed.',
|
|
266
|
+
};
|
|
267
|
+
if (Buffer.byteLength(JSON.stringify([item])) > cap) throw error();
|
|
268
|
+
const candidate = [...batch, item];
|
|
269
|
+
if (
|
|
270
|
+
batch.length &&
|
|
271
|
+
(candidate.length > maxItems || Buffer.byteLength(JSON.stringify(candidate)) > cap)
|
|
272
|
+
) {
|
|
273
|
+
batches.push(batch);
|
|
274
|
+
batch = [];
|
|
275
|
+
}
|
|
276
|
+
batch.push(item);
|
|
277
|
+
}
|
|
278
|
+
if (batch.length) batches.push(batch);
|
|
279
|
+
|
|
280
|
+
const factsById = new Map(facts.map((fact) => [fact.id, fact]));
|
|
281
|
+
for (const items of batches) {
|
|
282
|
+
for (const item of items) {
|
|
283
|
+
const evidence = factsById.get(item.id)?.evidence;
|
|
284
|
+
if (!evidence) continue;
|
|
285
|
+
item.representativeExcerpt = evidence;
|
|
286
|
+
if (Buffer.byteLength(JSON.stringify(items)) > cap) delete item.representativeExcerpt;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return batches;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function localPlanItems(config, facts, cap) {
|
|
293
|
+
const batches = localPlanBatches(config, facts, cap);
|
|
294
|
+
if (batches.length !== 1)
|
|
295
|
+
throw fail(
|
|
255
296
|
ERROR_CATEGORIES.CONFIG,
|
|
256
|
-
'The complete split candidate inventory
|
|
297
|
+
'The complete split candidate inventory requires multiple planning requests.',
|
|
298
|
+
{ data: { fallbackPlan: true } },
|
|
257
299
|
);
|
|
258
|
-
|
|
259
|
-
const items = facts.map((fact) => ({
|
|
260
|
-
...summaryOf(fact, false),
|
|
261
|
-
summary: 'Local candidate only; verify grouping. Content not fully analyzed.',
|
|
262
|
-
}));
|
|
263
|
-
if (Buffer.byteLength(JSON.stringify(items)) > cap) throw error();
|
|
264
|
-
for (const fact of ranked(facts)) {
|
|
265
|
-
if (!fact.evidence) continue;
|
|
266
|
-
const item = items.find((item) => item.id === fact.id);
|
|
267
|
-
item.representativeExcerpt = fact.evidence;
|
|
268
|
-
if (Buffer.byteLength(JSON.stringify(items)) > cap) delete item.representativeExcerpt;
|
|
269
|
-
}
|
|
270
|
-
return items;
|
|
300
|
+
return batches[0];
|
|
271
301
|
}
|
package/src/main.js
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
summarizeChanges,
|
|
30
30
|
} from './change-analysis.js';
|
|
31
31
|
import { cleanupGitSpools } from './git-spool.js';
|
|
32
|
+
import { createAnalysisCache } from './analysis-cache.js';
|
|
32
33
|
import { generateCommitMessage } from './api.js';
|
|
33
34
|
import {
|
|
34
35
|
statusColor,
|
|
@@ -110,6 +111,7 @@ async function runMain() {
|
|
|
110
111
|
splitPlanFile,
|
|
111
112
|
dryRun,
|
|
112
113
|
yes,
|
|
114
|
+
allowSingleFallback,
|
|
113
115
|
setup,
|
|
114
116
|
update,
|
|
115
117
|
doctor,
|
|
@@ -291,6 +293,7 @@ async function runMain() {
|
|
|
291
293
|
console.log(chalk.dim(` split: ${split}`));
|
|
292
294
|
console.log(chalk.dim(` dryRun: ${dryRun}`));
|
|
293
295
|
console.log(chalk.dim(` yes: ${yes}`));
|
|
296
|
+
console.log(chalk.dim(` singleFallback: ${allowSingleFallback}`));
|
|
294
297
|
console.log(chalk.dim(' final config:'));
|
|
295
298
|
for (const [key, value] of Object.entries(config)) {
|
|
296
299
|
const display =
|
|
@@ -321,6 +324,7 @@ async function runMain() {
|
|
|
321
324
|
scope: split,
|
|
322
325
|
dryRun,
|
|
323
326
|
yes,
|
|
327
|
+
allowSingleFallback,
|
|
324
328
|
machineOutput,
|
|
325
329
|
provider: selectedProvider,
|
|
326
330
|
exportPlanPath: splitCommand === 'plan' ? splitPlanFile : null,
|
|
@@ -572,9 +576,25 @@ async function runMain() {
|
|
|
572
576
|
);
|
|
573
577
|
let analysis;
|
|
574
578
|
let analyzedFacts;
|
|
579
|
+
let persistentAnalysisCache = null;
|
|
575
580
|
let generationConfig = config;
|
|
576
581
|
if (large) {
|
|
577
582
|
generationConfig = analysisConfig(config);
|
|
583
|
+
persistentAnalysisCache = createAnalysisCache({
|
|
584
|
+
projectRoot,
|
|
585
|
+
snapshotFingerprint: plannedIndexFingerprint,
|
|
586
|
+
config: generationConfig,
|
|
587
|
+
protect: protectAnalysis,
|
|
588
|
+
});
|
|
589
|
+
if (
|
|
590
|
+
generationConfig.largeChange?.strategy === 'deep' &&
|
|
591
|
+
generationConfig.largeChange.cache?.enabled &&
|
|
592
|
+
!protectAnalysis &&
|
|
593
|
+
!generationConfig.largeChange.cache.allowUnprotected
|
|
594
|
+
) {
|
|
595
|
+
warnings.push('Deep-analysis recovery cache was disabled for unprotected input.');
|
|
596
|
+
console.log(chalk.dim(' Recovery cache: disabled for unprotected input.'));
|
|
597
|
+
}
|
|
578
598
|
console.log(
|
|
579
599
|
chalk.dim(
|
|
580
600
|
generationConfig.largeChange?.strategy === 'deep'
|
|
@@ -622,8 +642,11 @@ async function runMain() {
|
|
|
622
642
|
captured,
|
|
623
643
|
protectAnalysis,
|
|
624
644
|
null,
|
|
625
|
-
({ completedChunks }) =>
|
|
626
|
-
console.error(
|
|
645
|
+
({ completedChunks, cachedChunks }) =>
|
|
646
|
+
console.error(
|
|
647
|
+
` Analysis: ${completedChunks} chunks completed${cachedChunks ? ` (${cachedChunks} cached)` : ''}`,
|
|
648
|
+
),
|
|
649
|
+
persistentAnalysisCache,
|
|
627
650
|
);
|
|
628
651
|
modelDiff =
|
|
629
652
|
analyzedFacts.summary ||
|
|
@@ -691,6 +714,8 @@ async function runMain() {
|
|
|
691
714
|
reported: true,
|
|
692
715
|
});
|
|
693
716
|
}
|
|
717
|
+
persistentAnalysisCache?.clear();
|
|
718
|
+
persistentAnalysisCache = null;
|
|
694
719
|
|
|
695
720
|
// ── 4. User action ─────────────────────────────────────────────────
|
|
696
721
|
|