@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.
@@ -12,7 +12,7 @@ import {
12
12
  isDeepAnalysis,
13
13
  isGeneratedFile,
14
14
  localOverview,
15
- localPlanItems,
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 = Math.max(
235
- config.maxTokens || 1024,
236
- config.reasoning?.mode === 'on' ? config.reasoning.maxTokens || 4096 : 0,
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
- async function jsonCall(config, instruction, items) {
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 all input IDs.',
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 JSON.parse(result.text.replace(/^```(?:json)?\s*|\s*```$/g, ''));
277
- } catch {
278
- throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Large-change analysis returned invalid JSON.');
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
- budget.reserveFinal(
320
- budget.limits.chunkInputTokens +
321
- Math.max(config.maxTokens || 1024, config.reasoning?.maxTokens || 4096),
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 length = 0;
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
- length = 0;
335
- const cacheKey = createHash('sha256')
336
- .update(
337
- JSON.stringify({
338
- version: 1,
339
- input,
340
- protect,
341
- model: config.modelId,
342
- endpoint: config.apiUrl,
343
- context: config.repositoryContextText,
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?.({ completedChunks: ++done, analyzedFragments: analyzed });
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).length;
391
- if (batch.length && (length + size > maxChars || batch.length >= 16)) await schedule();
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
- length += size;
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 = !isDeepAnalysis(config)
503
- ? [localPlanItems(config, candidates, cap)]
504
- : packItems(
505
- candidates.map(({ id, path, summary }) => ({ id, path, summary })),
506
- cap,
507
- config.splitMaxPlanFiles || 100,
508
- );
509
- if (!isDeepAnalysis(config) && coverage) {
510
- coverage.sampledFiles = batches[0].filter((item) => item.representativeExcerpt).length;
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
- validatePartition(
521
- groups,
522
- batch.map((x) => x.id),
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(config, key, { integer = false, min = -Infinity, max = Infinity } = {}) {
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 "${key}": expected a ${kind}${range}.`);
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');