@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.
@@ -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) {
@@ -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
- budget.reserveFinal(
389
- budget.limits.chunkInputTokens +
390
- 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,
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 length = 0;
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
- length = 0;
404
- const cacheKey = createHash('sha256')
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 groups =
418
- cache.get(cacheKey) ||
419
- (await jsonCall(
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
- (candidate) => {
424
- validatePartition(candidate, inputIds);
425
- if (candidate.some((group) => group.ids.length !== 1))
426
- throw fail(
427
- ERROR_CATEGORIES.RESPONSE_FORMAT,
428
- 'Fragment analysis combined unrelated source IDs.',
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?.({ completedChunks: ++done, analyzedFragments: analyzed });
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).length;
460
- 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();
461
607
  batch.push(unit);
462
- length += size;
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 = !isDeepAnalysis(config)
572
- ? [localPlanItems(config, candidates, cap)]
573
- : packItems(
574
- candidates.map(({ id, path, summary }) => ({ id, path, summary })),
575
- cap,
576
- config.splitMaxPlanFiles || 100,
577
- );
578
- if (!isDeepAnalysis(config) && coverage) {
579
- 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;
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(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');
@@ -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 localPlanItems(config, facts, cap) {
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 does not fit one request. Stage a smaller logical change or explicitly set personal largeChange.strategy to deep. No planning request was sent.',
297
+ 'The complete split candidate inventory requires multiple planning requests.',
298
+ { data: { fallbackPlan: true } },
257
299
  );
258
- if (facts.length > (config.splitMaxPlanFiles || 100)) throw error();
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(` Analysis: ${completedChunks} chunks completed`),
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