git-coco 0.42.0 → 0.44.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/dist/index.js CHANGED
@@ -6,6 +6,7 @@ var example_selectors = require('@langchain/core/example_selectors');
6
6
  var yargs = require('yargs');
7
7
  var chalk = require('chalk');
8
8
  var fs = require('fs');
9
+ var crypto = require('node:crypto');
9
10
  var ini = require('ini');
10
11
  var os = require('os');
11
12
  var path = require('path');
@@ -13,11 +14,12 @@ var Ajv = require('ajv');
13
14
  var ora = require('ora');
14
15
  var now = require('performance-now');
15
16
  var prettyMilliseconds = require('pretty-ms');
17
+ var fs$1 = require('node:fs');
18
+ var os$1 = require('node:os');
19
+ var path$1 = require('node:path');
16
20
  var anthropic = require('@langchain/anthropic');
17
21
  var ollama = require('@langchain/ollama');
18
22
  var openai = require('@langchain/openai');
19
- var fs$1 = require('node:fs');
20
- var path$1 = require('node:path');
21
23
  var output_parsers = require('@langchain/core/output_parsers');
22
24
  var minimatch = require('minimatch');
23
25
  var simpleGit = require('simple-git');
@@ -39,8 +41,6 @@ require('@langchain/core/utils/async_caller');
39
41
  var tiktoken = require('tiktoken');
40
42
  var child_process = require('child_process');
41
43
  var node_child_process = require('node:child_process');
42
- var os$1 = require('node:os');
43
- var crypto = require('node:crypto');
44
44
  var readline = require('readline');
45
45
  var util$1 = require('util');
46
46
  var crypto$1 = require('crypto');
@@ -64,13 +64,13 @@ function _interopNamespaceDefault(e) {
64
64
  }
65
65
 
66
66
  var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
67
+ var crypto__namespace = /*#__PURE__*/_interopNamespaceDefault(crypto);
67
68
  var ini__namespace = /*#__PURE__*/_interopNamespaceDefault(ini);
68
69
  var os__namespace = /*#__PURE__*/_interopNamespaceDefault(os);
69
70
  var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
70
71
  var fs__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(fs$1);
71
- var path__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(path$1);
72
72
  var os__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(os$1);
73
- var crypto__namespace = /*#__PURE__*/_interopNamespaceDefault(crypto);
73
+ var path__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(path$1);
74
74
  var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
75
75
 
76
76
  // This file is auto-generated - DO NOT EDIT
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.42.0";
81
+ const BUILD_VERSION = "0.44.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -254,6 +254,19 @@ const SUMMARIZE_PROMPT = new prompts.PromptTemplate({
254
254
  inputVariables: inputVariables$4,
255
255
  template: template$4,
256
256
  });
257
+ /**
258
+ * Stable fingerprint of the active summarization template (#845, PR 5).
259
+ *
260
+ * The diff-summary cache keys include this hash so any prompt edit
261
+ * invalidates prior cache entries automatically — no manual bumps,
262
+ * no stale outputs that no longer reflect the current prompt's voice
263
+ * or rules. Only the template body matters; whitespace differences
264
+ * still re-key the cache, which is the safe default.
265
+ */
266
+ const SUMMARIZE_PROMPT_HASH = crypto.createHash('sha256')
267
+ .update(template$4)
268
+ .digest('hex')
269
+ .slice(0, 16);
257
270
 
258
271
  /**
259
272
  * Base class for all LangChain-related errors
@@ -437,10 +450,21 @@ function getDefaultServiceApiKey(config) {
437
450
  }
438
451
  const DEFAULT_OPENAI_LLM_SERVICE = {
439
452
  provider: 'openai',
440
- model: 'gpt-4o-mini',
453
+ // Bumped from `gpt-4o-mini` to `gpt-4.1-nano` (#854). Diff
454
+ // condensing is bounded summarization — the cheaper / faster
455
+ // tier is the right default for it; quality is on par for this
456
+ // class of task. Users who want the older 4o-mini can still
457
+ // override via service config.
458
+ model: 'gpt-4.1-nano',
441
459
  tokenLimit: 4096,
442
460
  temperature: 0.32,
443
- maxConcurrent: 12,
461
+ // Bumped 12 → 24 (#845, PR 3). The OpenAI fast tier comfortably
462
+ // handles ~30 concurrent on the per-key default rate limit; 24
463
+ // leaves headroom for retries while still doubling throughput.
464
+ // The summarize chain has a 429-aware backoff (`summarize`
465
+ // helper) so a temporary rate-limit hit no longer kills the
466
+ // whole pipeline.
467
+ maxConcurrent: 24,
444
468
  minTokensForSummary: 800,
445
469
  maxFileTokens: 2000,
446
470
  authentication: {
@@ -452,10 +476,20 @@ const DEFAULT_OPENAI_LLM_SERVICE = {
452
476
  };
453
477
  const DEFAULT_ANTHROPIC_LLM_SERVICE = {
454
478
  provider: 'anthropic',
455
- model: 'claude-3-5-sonnet-20240620',
479
+ // Bumped from `claude-3-5-sonnet-20240620` to
480
+ // `claude-haiku-4-5-20251001` (#854). The Sonnet 3.5 default
481
+ // was nearly two model generations stale; Haiku 4.5 is the
482
+ // current fast tier and the right fit for diff summarization.
483
+ // Users who want Sonnet for quality-sensitive runs can still
484
+ // override via service config (recommended: `claude-sonnet-4-6`).
485
+ model: 'claude-haiku-4-5-20251001',
456
486
  temperature: 0.32,
457
487
  tokenLimit: 4096,
458
- maxConcurrent: 12,
488
+ // Bumped 12 → 24 (#845, PR 3). Matches the OpenAI default;
489
+ // Anthropic's per-key concurrency on Haiku is generous enough
490
+ // that 24 stays under the rate ceiling for typical fast-model
491
+ // request shapes. Backoff in `summarize` handles spikes.
492
+ maxConcurrent: 24,
459
493
  minTokensForSummary: 800,
460
494
  maxFileTokens: 2000,
461
495
  authentication: {
@@ -1439,6 +1473,10 @@ const schema$1 = {
1439
1473
  "AnthropicModel": {
1440
1474
  "type": "string",
1441
1475
  "enum": [
1476
+ "claude-sonnet-4-6",
1477
+ "claude-haiku-4-5-20251001",
1478
+ "claude-haiku-4-5",
1479
+ "claude-opus-4-7",
1442
1480
  "claude-sonnet-4-0",
1443
1481
  "claude-3-7-sonnet-latest",
1444
1482
  "claude-3-5-haiku-latest",
@@ -1972,6 +2010,44 @@ function parseServiceConfig(service) {
1972
2010
  }
1973
2011
  }
1974
2012
 
2013
+ /**
2014
+ * Ensure the canonical default ignore lists are always present in
2015
+ * the resolved config (#851). User-provided `ignoredFiles` /
2016
+ * `ignoredExtensions` arrays from XDG / git / project / env config
2017
+ * sources used to *replace* the defaults wholesale via the shallow
2018
+ * spread in each loader, which silently dropped lockfile + node_modules
2019
+ * entries the moment a user provided their own list. The reported
2020
+ * symptom: `pnpm-lock.yaml` reaching the diff-condensing pipeline
2021
+ * after a user added `.coco.config.json` for unrelated overrides.
2022
+ *
2023
+ * Now: user values are *unioned* with the defaults. Order is preserved
2024
+ * (defaults first, then user-only additions in their original order).
2025
+ * Duplicates are de-duped. The defaults can no longer be opted out of —
2026
+ * the cost of accidentally summarizing a lockfile (minutes of LLM time
2027
+ * per commit) outweighs the niche case of intentionally excluding a
2028
+ * default lockfile pattern.
2029
+ */
2030
+ function unionPreservingOrder(base, extras) {
2031
+ if (!extras || extras.length === 0)
2032
+ return [...base];
2033
+ const seen = new Set(base);
2034
+ const merged = [...base];
2035
+ for (const value of extras) {
2036
+ if (!seen.has(value)) {
2037
+ seen.add(value);
2038
+ merged.push(value);
2039
+ }
2040
+ }
2041
+ return merged;
2042
+ }
2043
+ function mergeIgnoreLists(config) {
2044
+ return {
2045
+ ...config,
2046
+ ignoredFiles: unionPreservingOrder(DEFAULT_IGNORED_FILES$1, config.ignoredFiles),
2047
+ ignoredExtensions: unionPreservingOrder(DEFAULT_IGNORED_EXTENSIONS$1, config.ignoredExtensions),
2048
+ };
2049
+ }
2050
+
1975
2051
  /**
1976
2052
  * Tracked config sources populated during the last loadConfig call.
1977
2053
  * Useful for diagnostics (e.g. `coco doctor`).
@@ -2022,6 +2098,13 @@ function loadConfig(argv = {}) {
2022
2098
  config = envConfig;
2023
2099
  if (envActive)
2024
2100
  sources.push({ source: 'env' });
2101
+ // Re-apply the canonical default ignore lists after every loader has
2102
+ // had a chance to override (#851). Each loader replaces ignoredFiles
2103
+ // / ignoredExtensions wholesale via shallow spread, which used to
2104
+ // silently drop the lockfile + node_modules defaults the moment a
2105
+ // user provided their own list. The merge is a union — defaults first,
2106
+ // user-only entries appended.
2107
+ config = mergeIgnoreLists(config);
2025
2108
  _lastConfigSources = sources;
2026
2109
  return { ...config, ...argv };
2027
2110
  }
@@ -2196,6 +2279,232 @@ function commandExecutor(handler) {
2196
2279
  };
2197
2280
  }
2198
2281
 
2282
+ const command$8 = 'cache <subcommand>';
2283
+ const builder$8 = (yargs) => {
2284
+ return yargs
2285
+ .positional('subcommand', {
2286
+ describe: 'Cache action to run (clear, info)',
2287
+ type: 'string',
2288
+ choices: ['clear', 'info'],
2289
+ })
2290
+ .usage(getCommandUsageHeader(command$8));
2291
+ };
2292
+
2293
+ /**
2294
+ * Per-repo disk cache of LLM-summarized diffs (#845, PR 5). On a
2295
+ * re-run of `coco commit` after a small change, most files have
2296
+ * unchanged content and unchanged diffs — caching their summaries
2297
+ * by content hash means the second run skips the LLM entirely for
2298
+ * those files and only pays for what's actually different.
2299
+ *
2300
+ * Strict best-effort: read failures fall back to "no cache" (the
2301
+ * pipeline runs the LLM as before), and write failures are
2302
+ * swallowed silently. The cache is never load-bearing.
2303
+ *
2304
+ * Repos are keyed by a short hash of their absolute path. No PII
2305
+ * in the cache filename, and re-creating a repo at the same path
2306
+ * keeps the same cache.
2307
+ *
2308
+ * Cache key: `sha256(diff + ':' + model + ':' + promptHash)`.
2309
+ * - diff: the literal diff text being summarized
2310
+ * - model: switching models invalidates (different summaries)
2311
+ * - promptHash: editing the SUMMARIZE_PROMPT template invalidates
2312
+ *
2313
+ * Cap: 500 entries per repo. LRU eviction on overflow keeps the
2314
+ * cache file under ~500 KB on a typical repo (each entry is a
2315
+ * sha256 hash + 200-500-byte summary).
2316
+ */
2317
+ const CACHE_SCHEMA_VERSION$1 = 1;
2318
+ const CACHE_DIR_NAME$1 = 'diff-summaries';
2319
+ const CACHE_ENTRY_HARD_CAP = 500;
2320
+ function resolveCacheDir$4() {
2321
+ const xdg = process.env.XDG_CACHE_HOME;
2322
+ if (xdg && xdg.trim().length > 0) {
2323
+ return path__namespace$1.join(xdg, 'coco', CACHE_DIR_NAME$1);
2324
+ }
2325
+ return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$1);
2326
+ }
2327
+ function repoKey$3(repoPath) {
2328
+ // sha256 here is a non-security cache-key derivation — deterministic
2329
+ // short identifier for the cache filename so two repos at different
2330
+ // paths never collide. We truncate to 16 chars; collision-resistance
2331
+ // against an adversary is not required.
2332
+ return crypto__namespace.createHash('sha256').update(repoPath).digest('hex').slice(0, 16);
2333
+ }
2334
+ function getDiffSummaryCachePath(repoPath) {
2335
+ return path__namespace$1.join(resolveCacheDir$4(), `summaries.${repoKey$3(repoPath)}.json`);
2336
+ }
2337
+ /**
2338
+ * Build the cache key for a (diff, model, prompt) tuple. sha256
2339
+ * because we want a strong content-hash; the per-entry storage cost
2340
+ * is dominated by the summary text anyway.
2341
+ */
2342
+ function diffSummaryKey(diff, model, promptHash) {
2343
+ return crypto__namespace
2344
+ .createHash('sha256')
2345
+ .update(`${diff}\x1f${model}\x1f${promptHash}`)
2346
+ .digest('hex');
2347
+ }
2348
+ function readEnvelope(filePath) {
2349
+ try {
2350
+ const raw = fs__namespace$1.readFileSync(filePath, 'utf8');
2351
+ const parsed = JSON.parse(raw);
2352
+ if (parsed.version !== CACHE_SCHEMA_VERSION$1)
2353
+ return undefined;
2354
+ if (!parsed.entries || typeof parsed.entries !== 'object')
2355
+ return undefined;
2356
+ return parsed;
2357
+ }
2358
+ catch {
2359
+ return undefined;
2360
+ }
2361
+ }
2362
+ function readDiffSummary(repoPath, key) {
2363
+ const envelope = readEnvelope(getDiffSummaryCachePath(repoPath));
2364
+ if (!envelope)
2365
+ return undefined;
2366
+ const entry = envelope.entries[key];
2367
+ if (!entry)
2368
+ return undefined;
2369
+ return entry;
2370
+ }
2371
+ function writeDiffSummary(repoPath, key, entry) {
2372
+ const filePath = getDiffSummaryCachePath(repoPath);
2373
+ const existing = readEnvelope(filePath) || {
2374
+ version: CACHE_SCHEMA_VERSION$1,
2375
+ savedAt: new Date().toISOString(),
2376
+ entries: {},
2377
+ };
2378
+ existing.entries[key] = { ...entry, lastAccessedAt: new Date().toISOString() };
2379
+ existing.savedAt = new Date().toISOString();
2380
+ const evictedEntries = enforceHardCap(existing.entries);
2381
+ if (evictedEntries.length > 0) {
2382
+ for (const evicted of evictedEntries) {
2383
+ delete existing.entries[evicted];
2384
+ }
2385
+ }
2386
+ try {
2387
+ fs__namespace$1.mkdirSync(path__namespace$1.dirname(filePath), { recursive: true });
2388
+ fs__namespace$1.writeFileSync(filePath, JSON.stringify(existing));
2389
+ }
2390
+ catch {
2391
+ // Best-effort persistence; swallow.
2392
+ }
2393
+ }
2394
+ /**
2395
+ * Touch an existing entry's lastAccessedAt so LRU eviction prefers
2396
+ * dropping older / unused entries. Caller is expected to know the
2397
+ * entry exists (read returned a hit).
2398
+ */
2399
+ function touchDiffSummary(repoPath, key) {
2400
+ const filePath = getDiffSummaryCachePath(repoPath);
2401
+ const envelope = readEnvelope(filePath);
2402
+ if (!envelope || !envelope.entries[key])
2403
+ return;
2404
+ envelope.entries[key] = {
2405
+ ...envelope.entries[key],
2406
+ lastAccessedAt: new Date().toISOString(),
2407
+ };
2408
+ envelope.savedAt = new Date().toISOString();
2409
+ try {
2410
+ fs__namespace$1.writeFileSync(filePath, JSON.stringify(envelope));
2411
+ }
2412
+ catch {
2413
+ // Swallow.
2414
+ }
2415
+ }
2416
+ function enforceHardCap(entries) {
2417
+ const keys = Object.keys(entries);
2418
+ if (keys.length <= CACHE_ENTRY_HARD_CAP)
2419
+ return [];
2420
+ // Sort by lastAccessedAt ascending (oldest first), drop the
2421
+ // oldest (keys.length - CACHE_ENTRY_HARD_CAP) entries.
2422
+ const sorted = keys
2423
+ .map((key) => ({ key, accessed: Date.parse(entries[key].lastAccessedAt) || 0 }))
2424
+ .sort((a, b) => a.accessed - b.accessed);
2425
+ const toEvict = sorted.slice(0, keys.length - CACHE_ENTRY_HARD_CAP).map((entry) => entry.key);
2426
+ return toEvict;
2427
+ }
2428
+ /** Remove the entire cache file for the repo. Used by `coco cache:clear`. */
2429
+ function clearDiffSummaryCache(repoPath) {
2430
+ const filePath = getDiffSummaryCachePath(repoPath);
2431
+ if (!fs__namespace$1.existsSync(filePath)) {
2432
+ return { ok: true, removed: false };
2433
+ }
2434
+ try {
2435
+ fs__namespace$1.unlinkSync(filePath);
2436
+ return { ok: true, removed: true };
2437
+ }
2438
+ catch {
2439
+ return { ok: false, removed: false };
2440
+ }
2441
+ }
2442
+
2443
+ function readEnvelopeOrUndefined(filePath) {
2444
+ try {
2445
+ if (!fs__namespace$1.existsSync(filePath))
2446
+ return undefined;
2447
+ const raw = fs__namespace$1.readFileSync(filePath, 'utf8');
2448
+ return JSON.parse(raw);
2449
+ }
2450
+ catch {
2451
+ return undefined;
2452
+ }
2453
+ }
2454
+ function formatBytes(bytes) {
2455
+ if (bytes < 1024)
2456
+ return `${bytes} B`;
2457
+ if (bytes < 1024 * 1024)
2458
+ return `${(bytes / 1024).toFixed(1)} KB`;
2459
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
2460
+ }
2461
+ const handler$8 = async (argv, logger) => {
2462
+ const subcommand = argv.subcommand;
2463
+ const repoPath = process.cwd();
2464
+ const cachePath = getDiffSummaryCachePath(repoPath);
2465
+ if (subcommand === 'clear') {
2466
+ const result = clearDiffSummaryCache(repoPath);
2467
+ if (!result.ok) {
2468
+ logger.log(chalk.red(`Failed to clear diff-summary cache at ${cachePath}`));
2469
+ process.exitCode = 1;
2470
+ return;
2471
+ }
2472
+ if (result.removed) {
2473
+ logger.log(chalk.green(`Cleared diff-summary cache at ${cachePath}`));
2474
+ }
2475
+ else {
2476
+ logger.log(chalk.dim(`No diff-summary cache to clear (${cachePath})`));
2477
+ }
2478
+ return;
2479
+ }
2480
+ if (subcommand === 'info') {
2481
+ const envelope = readEnvelopeOrUndefined(cachePath);
2482
+ if (!envelope) {
2483
+ logger.log(chalk.dim(`No diff-summary cache for this repo (${cachePath})`));
2484
+ return;
2485
+ }
2486
+ const stat = fs__namespace$1.statSync(cachePath);
2487
+ const entryCount = Object.keys(envelope.entries).length;
2488
+ const totalSummaryTokens = Object.values(envelope.entries).reduce((sum, entry) => sum + entry.tokens, 0);
2489
+ logger.log(chalk.bold('Diff-summary cache') + ` ${chalk.dim(cachePath)}`);
2490
+ logger.log(` ${chalk.green('entries')} ${entryCount}`);
2491
+ logger.log(` ${chalk.green('on-disk size')} ${formatBytes(stat.size)}`);
2492
+ logger.log(` ${chalk.green('summary tokens')} ${totalSummaryTokens}`);
2493
+ logger.log(` ${chalk.green('last saved')} ${envelope.savedAt}`);
2494
+ return;
2495
+ }
2496
+ logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`));
2497
+ logger.log(chalk.dim('Use one of: clear, info'));
2498
+ process.exitCode = 1;
2499
+ };
2500
+
2501
+ var cache = {
2502
+ command: command$8,
2503
+ desc: 'Manage the diff-summary cache (clear, info)',
2504
+ builder: builder$8,
2505
+ handler: commandExecutor(handler$8),
2506
+ };
2507
+
2199
2508
  var util;
2200
2509
  (function (util) {
2201
2510
  util.assertEqual = (_) => { };
@@ -7530,6 +7839,56 @@ function getPathFromFilePath(filePath) {
7530
7839
  return filePath.split('/').slice(0, -1).join('/');
7531
7840
  }
7532
7841
 
7842
+ /**
7843
+ * Adaptive backoff (#845, PR 3). Wraps the chain invocation so a
7844
+ * transient 429 (rate limit) or 5xx no longer kills the whole
7845
+ * pipeline — instead we wait briefly and retry up to N times
7846
+ * before surfacing the failure.
7847
+ *
7848
+ * Cap is intentionally short. Diff condensing fans out to many
7849
+ * concurrent calls; if rate limits hit hard, queueing requests
7850
+ * indefinitely just makes the user wait longer for a result the
7851
+ * pipeline ultimately handles via fewer concurrent passes anyway.
7852
+ * 3 retries with 1s/2s/4s waits trade ~7s of worst-case extra
7853
+ * latency for resilience to brief rate-limit blips.
7854
+ */
7855
+ const BACKOFF_RETRIES = 3;
7856
+ const BACKOFF_BASE_MS = 1000;
7857
+ const BACKOFF_CAP_MS = 5000;
7858
+ function isRetryableError(error) {
7859
+ if (!error || typeof error !== 'object')
7860
+ return false;
7861
+ const err = error;
7862
+ if (err.status === 429 || err.status === 503 || err.status === 502 || err.status === 504) {
7863
+ return true;
7864
+ }
7865
+ if (err.code === 429 || err.code === 'rate_limit_exceeded' || err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT') {
7866
+ return true;
7867
+ }
7868
+ if (typeof err.message === 'string' && /(rate.?limit|429|too many requests|timeout|temporarily unavailable)/i.test(err.message)) {
7869
+ return true;
7870
+ }
7871
+ return false;
7872
+ }
7873
+ async function invokeWithBackoff(chain, input, logger) {
7874
+ let lastError;
7875
+ for (let attempt = 0; attempt <= BACKOFF_RETRIES; attempt++) {
7876
+ try {
7877
+ return await chain.invoke(input);
7878
+ }
7879
+ catch (error) {
7880
+ lastError = error;
7881
+ if (!isRetryableError(error) || attempt === BACKOFF_RETRIES) {
7882
+ throw error;
7883
+ }
7884
+ const wait = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * Math.pow(2, attempt));
7885
+ logger?.verbose(`[summarize] retryable error (attempt ${attempt + 1}/${BACKOFF_RETRIES}); backing off ${wait}ms`, { color: 'yellow' });
7886
+ await new Promise((resolve) => setTimeout(resolve, wait));
7887
+ }
7888
+ }
7889
+ // Unreachable — the loop either returns or rethrows above.
7890
+ throw lastError;
7891
+ }
7533
7892
  async function summarize(documents$1, { chain, textSplitter, options, logger, tokenizer, metadata }) {
7534
7893
  const { returnIntermediateSteps = false } = options || {};
7535
7894
  const docs = await textSplitter.splitDocuments(documents$1.map((doc) => new documents.Document(doc)));
@@ -7537,10 +7896,10 @@ async function summarize(documents$1, { chain, textSplitter, options, logger, to
7537
7896
  ? docs.reduce((sum, doc) => sum + tokenizer(doc.pageContent), 0)
7538
7897
  : undefined;
7539
7898
  const startedAt = Date.now();
7540
- const res = await chain.invoke({
7899
+ const res = await invokeWithBackoff(chain, {
7541
7900
  input_documents: docs,
7542
7901
  returnIntermediateSteps,
7543
- });
7902
+ }, logger);
7544
7903
  const elapsedMs = Date.now() - startedAt;
7545
7904
  logLlmCall(logger, {
7546
7905
  task: 'summarize',
@@ -7555,10 +7914,175 @@ async function summarize(documents$1, { chain, textSplitter, options, logger, to
7555
7914
  return res.text && res.text.trim();
7556
7915
  }
7557
7916
 
7917
+ /**
7918
+ * Inspect a unified-diff string and report its shape, or undefined
7919
+ * if the diff isn't trivial (mixed +/- lines, weird headers, etc.).
7920
+ *
7921
+ * Detection rules (cheap on purpose — we're called per-file and the
7922
+ * goal is to skip work, not be exhaustive):
7923
+ *
7924
+ * - `Binary files ... differ` header → 'binary'
7925
+ * - `rename from`/`rename to` headers and no `+`/`-` content
7926
+ * lines → 'rename'
7927
+ * - All content lines are `+` (and at least one is) → 'addition'
7928
+ * - All content lines are `-` (and at least one is) → 'deletion'
7929
+ * - Otherwise → undefined (let the LLM handle it)
7930
+ */
7931
+ function detectTrivialDiffShape(diff) {
7932
+ if (!diff)
7933
+ return undefined;
7934
+ // Binary marker is unambiguous and short-circuits early.
7935
+ if (/^Binary files .+ and .+ differ$/m.test(diff)) {
7936
+ return 'binary';
7937
+ }
7938
+ // Pure rename: git emits `rename from` / `rename to` and no body.
7939
+ // We require BOTH markers AND no `+`/`-` content lines. Some
7940
+ // renames-with-edit show rename headers AND a hunk; those should
7941
+ // fall through to the LLM path.
7942
+ const hasRenameFrom = /^rename from /m.test(diff);
7943
+ const hasRenameTo = /^rename to /m.test(diff);
7944
+ if (hasRenameFrom && hasRenameTo) {
7945
+ const hasContentChange = diff
7946
+ .split('\n')
7947
+ .some((line) => isContentChangeLine(line));
7948
+ if (!hasContentChange) {
7949
+ return 'rename';
7950
+ }
7951
+ }
7952
+ // Walk the body once classifying content lines. We skip header
7953
+ // lines (diff --git, index, ---, +++, @@, etc.) and only inspect
7954
+ // the lines that represent actual change content.
7955
+ let plus = 0;
7956
+ let minus = 0;
7957
+ for (const line of diff.split('\n')) {
7958
+ if (isHeaderLine(line))
7959
+ continue;
7960
+ if (line.startsWith('+'))
7961
+ plus++;
7962
+ else if (line.startsWith('-'))
7963
+ minus++;
7964
+ // Context lines (' ' prefix) are ignored for shape classification:
7965
+ // a pure addition can still have surrounding context if a hunk
7966
+ // anchors at line 0, though `git diff` for a brand-new file
7967
+ // typically has none.
7968
+ }
7969
+ if (plus > 0 && minus === 0)
7970
+ return 'addition';
7971
+ if (minus > 0 && plus === 0)
7972
+ return 'deletion';
7973
+ return undefined;
7974
+ }
7975
+ /**
7976
+ * Build a deterministic summary string for a trivial diff. Returns
7977
+ * undefined when the shape can't be templated (caller should fall
7978
+ * back to the LLM path).
7979
+ */
7980
+ function summarizeTrivialDiff(fileDiff) {
7981
+ const shape = detectTrivialDiffShape(fileDiff.diff);
7982
+ if (!shape)
7983
+ return undefined;
7984
+ const lineCount = countContentLines(fileDiff.diff, shape);
7985
+ switch (shape) {
7986
+ case 'addition':
7987
+ return `Added \`${fileDiff.file}\` (${lineCount} line${lineCount === 1 ? '' : 's'}).`;
7988
+ case 'deletion':
7989
+ return `Removed \`${fileDiff.file}\` (${lineCount} line${lineCount === 1 ? '' : 's'}).`;
7990
+ case 'rename': {
7991
+ const oldPath = extractRenameOldPath(fileDiff.diff);
7992
+ return oldPath
7993
+ ? `Renamed \`${oldPath}\` → \`${fileDiff.file}\`.`
7994
+ : `Renamed file to \`${fileDiff.file}\`.`;
7995
+ }
7996
+ case 'binary':
7997
+ return `Updated binary file \`${fileDiff.file}\`.`;
7998
+ }
7999
+ }
8000
+ function isHeaderLine(line) {
8001
+ return (line.startsWith('diff --git') ||
8002
+ line.startsWith('index ') ||
8003
+ line.startsWith('--- ') ||
8004
+ line.startsWith('+++ ') ||
8005
+ line.startsWith('@@') ||
8006
+ line.startsWith('new file mode') ||
8007
+ line.startsWith('deleted file mode') ||
8008
+ line.startsWith('similarity index') ||
8009
+ line.startsWith('rename from ') ||
8010
+ line.startsWith('rename to ') ||
8011
+ line.startsWith('Binary files '));
8012
+ }
8013
+ function isContentChangeLine(line) {
8014
+ if (isHeaderLine(line))
8015
+ return false;
8016
+ return line.startsWith('+') || line.startsWith('-');
8017
+ }
8018
+ function countContentLines(diff, shape) {
8019
+ if (shape === 'binary' || shape === 'rename')
8020
+ return 0;
8021
+ const prefix = shape === 'addition' ? '+' : '-';
8022
+ let count = 0;
8023
+ for (const line of diff.split('\n')) {
8024
+ if (isHeaderLine(line))
8025
+ continue;
8026
+ if (line.startsWith(prefix))
8027
+ count++;
8028
+ }
8029
+ return count;
8030
+ }
8031
+ function extractRenameOldPath(diff) {
8032
+ const match = diff.match(/^rename from (.+)$/m);
8033
+ return match ? match[1].trim() : undefined;
8034
+ }
8035
+
8036
+ /**
8037
+ * Cache opt-out: COCO_NO_CACHE=1 disables both reads and writes
8038
+ * for the diff-summary cache (#845, PR 5). Default is enabled.
8039
+ */
8040
+ function isCacheEnabled$1() {
8041
+ return !process.env.COCO_NO_CACHE || process.env.COCO_NO_CACHE === '0';
8042
+ }
7558
8043
  /**
7559
8044
  * Summarize a single file diff that exceeds the token threshold.
8045
+ *
8046
+ * Trivial-shape short-circuit (#845, PR 2): pure additions / deletions
8047
+ * / renames / binary changes have no information content beyond the
8048
+ * diff's shape, so we templated-summarize them instead of paying for
8049
+ * an LLM call. On initial-commit fixtures (lots of pure adds) this
8050
+ * collapses the per-file summary phase entirely; the resulting tiny
8051
+ * synthetic summaries usually drop the directory token totals under
8052
+ * budget so wave consolidation skips too.
7560
8053
  */
7561
8054
  async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, logger, metadata, }) {
8055
+ const trivialSummary = summarizeTrivialDiff(fileDiff);
8056
+ if (trivialSummary !== undefined) {
8057
+ logger.verbose(` - ${fileDiff.file}: trivial-shape skip (no LLM call)`, { color: 'gray' });
8058
+ return {
8059
+ ...fileDiff,
8060
+ diff: trivialSummary,
8061
+ tokenCount: tokenizer(trivialSummary),
8062
+ };
8063
+ }
8064
+ // Cache lookup (#845, PR 5). Keyed on the file's literal diff
8065
+ // content + the active model + the summarization prompt hash.
8066
+ // A hit returns the prior summary instantly; on iterative
8067
+ // `coco commit` re-runs after small edits, the unchanged files
8068
+ // never go to the LLM.
8069
+ const cacheModel = typeof metadata?.model === 'string' ? metadata.model : undefined;
8070
+ const cacheRepo = process.cwd();
8071
+ const cacheKey = isCacheEnabled$1() && cacheModel
8072
+ ? diffSummaryKey(fileDiff.diff, cacheModel, SUMMARIZE_PROMPT_HASH)
8073
+ : undefined;
8074
+ if (cacheKey) {
8075
+ const cached = readDiffSummary(cacheRepo, cacheKey);
8076
+ if (cached) {
8077
+ logger.verbose(` - ${fileDiff.file}: cache hit (skipped LLM, ${cached.tokens} tokens)`, { color: 'cyan' });
8078
+ touchDiffSummary(cacheRepo, cacheKey);
8079
+ return {
8080
+ ...fileDiff,
8081
+ diff: cached.summary,
8082
+ tokenCount: cached.tokens,
8083
+ };
8084
+ }
8085
+ }
7562
8086
  try {
7563
8087
  const fileSummary = await summarize([
7564
8088
  {
@@ -7582,6 +8106,13 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
7582
8106
  },
7583
8107
  });
7584
8108
  const newTokenCount = tokenizer(fileSummary);
8109
+ if (cacheKey && cacheModel) {
8110
+ writeDiffSummary(cacheRepo, cacheKey, {
8111
+ summary: fileSummary,
8112
+ model: cacheModel,
8113
+ tokens: newTokenCount,
8114
+ });
8115
+ }
7585
8116
  return {
7586
8117
  ...fileDiff,
7587
8118
  diff: fileSummary,
@@ -7595,16 +8126,41 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
7595
8126
  }
7596
8127
  }
7597
8128
  /**
7598
- * Process files in waves to respect concurrency limits.
8129
+ * Continuous-queue scheduler (#845, PR 4). Mirrors the directory-
8130
+ * level scheduler in `summarizeDiffs.ts` and replaces the previous
8131
+ * fixed-wave Promise.all loop, which made the slowest call in
8132
+ * each wave block the next wave from starting. With realistic LLM
8133
+ * tail variance, that wave-locking adds dead time at every wave
8134
+ * boundary; continuous queue fills slots as in-flight calls
8135
+ * resolve, so the wall-clock tracks the slowest *call*, not the
8136
+ * sum of slowest-per-wave.
7599
8137
  */
7600
8138
  async function processInWaves$1(items, processor, maxConcurrent) {
7601
- const results = [];
7602
- for (let i = 0; i < items.length; i += maxConcurrent) {
7603
- const wave = items.slice(i, i + maxConcurrent);
7604
- const waveResults = await Promise.all(wave.map(processor));
7605
- results.push(...waveResults);
7606
- }
7607
- return results;
8139
+ const limit = createLimit$2(maxConcurrent);
8140
+ return Promise.all(items.map((item) => limit(() => processor(item))));
8141
+ }
8142
+ function createLimit$2(maxConcurrent) {
8143
+ const limit = Math.max(1, maxConcurrent);
8144
+ let active = 0;
8145
+ const queue = [];
8146
+ const runNext = () => {
8147
+ active--;
8148
+ const next = queue.shift();
8149
+ if (next)
8150
+ next();
8151
+ };
8152
+ return async (operation) => {
8153
+ if (active >= limit) {
8154
+ await new Promise((resolve) => queue.push(resolve));
8155
+ }
8156
+ active++;
8157
+ try {
8158
+ return await operation();
8159
+ }
8160
+ finally {
8161
+ runNext();
8162
+ }
8163
+ };
7608
8164
  }
7609
8165
  /**
7610
8166
  * Pre-summarize individual files that exceed the maxFileTokens threshold.
@@ -7669,6 +8225,13 @@ async function preprocessLargeFiles(rootNode, options) {
7669
8225
  return rebuildNode(rootNode);
7670
8226
  }
7671
8227
 
8228
+ /**
8229
+ * Cache opt-out: COCO_NO_CACHE=1 disables both reads and writes
8230
+ * for the diff-summary cache (#845, PR 5). Default is enabled.
8231
+ */
8232
+ function isCacheEnabled() {
8233
+ return !process.env.COCO_NO_CACHE || process.env.COCO_NO_CACHE === '0';
8234
+ }
7672
8235
  /**
7673
8236
  * Create groups from a given node info.
7674
8237
  * @param {DiffNode} node - The node info to start grouping.
@@ -7694,6 +8257,32 @@ function createDirectoryDiffs(node) {
7694
8257
  * Summarize a directory diff asynchronously.
7695
8258
  */
7696
8259
  async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenizer, logger, metadata }) {
8260
+ // Cache lookup (#845, PR 5). Joined per-file diffs become the
8261
+ // payload signature; if every file in the directory is unchanged
8262
+ // since the last run (and the model + prompt match), the prior
8263
+ // directory-level summary is reused instead of paying for another
8264
+ // map_reduce pass.
8265
+ const cacheModel = typeof metadata?.model === 'string' ? metadata.model : undefined;
8266
+ const cacheRepo = process.cwd();
8267
+ const cachePayload = directory.diffs
8268
+ .map((diff) => `${diff.file}\x1e${diff.diff}`)
8269
+ .join('\x1d');
8270
+ const cacheKey = isCacheEnabled() && cacheModel
8271
+ ? diffSummaryKey(cachePayload, cacheModel, SUMMARIZE_PROMPT_HASH)
8272
+ : undefined;
8273
+ if (cacheKey) {
8274
+ const cached = readDiffSummary(cacheRepo, cacheKey);
8275
+ if (cached) {
8276
+ logger?.verbose?.(` • Cache hit for "/${directory.path}" (skipped LLM, ${cached.tokens} tokens)`, { color: 'cyan' });
8277
+ touchDiffSummary(cacheRepo, cacheKey);
8278
+ return {
8279
+ diffs: directory.diffs,
8280
+ path: directory.path,
8281
+ summary: cached.summary,
8282
+ tokenCount: cached.tokens,
8283
+ };
8284
+ }
8285
+ }
7697
8286
  try {
7698
8287
  const directorySummary = await summarize(directory.diffs.map((diff) => ({
7699
8288
  pageContent: diff.diff,
@@ -7715,6 +8304,13 @@ async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenize
7715
8304
  },
7716
8305
  });
7717
8306
  const newTokenTotal = tokenizer(directorySummary);
8307
+ if (cacheKey && cacheModel) {
8308
+ writeDiffSummary(cacheRepo, cacheKey, {
8309
+ summary: directorySummary,
8310
+ model: cacheModel,
8311
+ tokens: newTokenTotal,
8312
+ });
8313
+ }
7718
8314
  return {
7719
8315
  diffs: directory.diffs,
7720
8316
  path: directory.path,
@@ -7749,66 +8345,99 @@ const defaultOutputCallback = (group) => {
7749
8345
  return output;
7750
8346
  };
7751
8347
  /**
7752
- * Process directory summarization in waves to respect concurrency limits
7753
- * while maintaining predictable behavior.
8348
+ * Continuous-queue scheduler for the directory summarization pass
8349
+ * (#845, PR 4). The previous wave-by-wave Promise.all forced the
8350
+ * scheduler to wait for the slowest call in a wave before starting
8351
+ * the next wave; on a fixture like `refactor` (20 directories, mixed
8352
+ * sizes) one big directory could pin the wave at ~its own latency
8353
+ * even though the other 19 calls finished long before.
8354
+ *
8355
+ * The continuous queue dispatches all eligible directories through
8356
+ * a `createLimit(maxConcurrent)` semaphore — same primitive
8357
+ * `collectDiffs` already uses. As soon as any in-flight summary
8358
+ * resolves, the next eligible directory takes its slot. Each
8359
+ * scheduled call also re-checks the budget at the moment it would
8360
+ * fire; if the budget is already met (because earlier completions
8361
+ * dropped the total under maxTokens), it returns the original
8362
+ * directory without an LLM call. So the work scales with what's
8363
+ * actually needed, not with the worst-case wave count.
8364
+ *
8365
+ * Order discipline is preserved: directories are sorted by token
8366
+ * count descending and dispatched in that order. The biggest
8367
+ * candidates land in the first batch of in-flight calls; as smaller
8368
+ * candidates reach the queue front, the budget is more likely to
8369
+ * already be met and they short-circuit.
7754
8370
  */
7755
8371
  async function summarizeInWaves(directories, options) {
7756
8372
  const { totalTokenCount: initialTotal, maxTokens, minTokensForSummary, maxConcurrent, logger, chain, textSplitter, tokenizer, metadata, } = options;
7757
8373
  let totalTokenCount = initialTotal;
7758
8374
  const results = [...directories];
7759
- // Create sorted indices by token count (descending) for prioritized processing
7760
- const sortedIndices = directories
8375
+ // Pick eligible directories upfront, sorted big-first.
8376
+ const eligibleIndices = directories
7761
8377
  .map((d, i) => ({ index: i, tokens: d.tokenCount }))
7762
- .sort((a, b) => b.tokens - a.tokens);
7763
- let cursor = 0;
7764
- while (totalTokenCount > maxTokens && cursor < sortedIndices.length) {
7765
- // Select wave candidates: directories that exceed minTokensForSummary
7766
- const wave = [];
7767
- for (let i = cursor; i < sortedIndices.length && wave.length < maxConcurrent; i++) {
7768
- const { index, tokens } = sortedIndices[i];
7769
- // Skip directories below the minimum threshold
7770
- if (tokens < minTokensForSummary) {
7771
- cursor = i + 1;
7772
- continue;
7773
- }
7774
- // Skip directories that have already been summarized
7775
- if (results[index].summary) {
7776
- cursor = i + 1;
7777
- continue;
7778
- }
7779
- wave.push(index);
7780
- cursor = i + 1;
7781
- }
7782
- // No more eligible candidates
7783
- if (wave.length === 0) {
7784
- break;
8378
+ .filter((entry) => entry.tokens >= minTokensForSummary && !results[entry.index].summary)
8379
+ .sort((a, b) => b.tokens - a.tokens)
8380
+ .map((entry) => entry.index);
8381
+ if (eligibleIndices.length === 0 || totalTokenCount <= maxTokens) {
8382
+ return { directories: results, totalTokenCount };
8383
+ }
8384
+ const limit = createLimit$1(maxConcurrent);
8385
+ logger.verbose(`\nProcessing ${eligibleIndices.length} directories with continuous queue (concurrency ${maxConcurrent})...`, { color: 'blue' });
8386
+ await Promise.all(eligibleIndices.map((idx) => limit(async () => {
8387
+ // Re-check the budget at dispatch time. Earlier completions
8388
+ // may have already dropped the total under the cap; in that
8389
+ // case skip the LLM call entirely.
8390
+ if (totalTokenCount <= maxTokens) {
8391
+ return;
7785
8392
  }
7786
- logger.verbose(`\nProcessing wave of ${wave.length} directories...`, { color: 'blue' });
7787
- // Process wave in parallel
7788
- const waveResults = await Promise.all(wave.map((idx) => summarizeDirectoryDiff(results[idx], { chain, textSplitter, tokenizer, logger, metadata })));
7789
- // Update results and recalculate total
7790
- waveResults.forEach((result, i) => {
7791
- const idx = wave[i];
7792
- const originalTokens = results[idx].tokenCount;
7793
- const newTokens = result.tokenCount;
7794
- const reduction = originalTokens - newTokens;
7795
- totalTokenCount -= reduction;
7796
- results[idx] = result;
7797
- logger.verbose(` • Summarized "/${result.path}": ${originalTokens} -> ${newTokens} tokens`, {
7798
- color: 'magenta',
7799
- });
8393
+ const result = await summarizeDirectoryDiff(results[idx], {
8394
+ chain,
8395
+ textSplitter,
8396
+ tokenizer,
8397
+ logger,
8398
+ metadata,
7800
8399
  });
7801
- logger.verbose(`Total token count: ${totalTokenCount}`, {
7802
- color: totalTokenCount > maxTokens ? 'yellow' : 'green',
8400
+ const originalTokens = results[idx].tokenCount;
8401
+ const newTokens = result.tokenCount;
8402
+ totalTokenCount -= (originalTokens - newTokens);
8403
+ results[idx] = result;
8404
+ logger.verbose(` • Summarized "/${result.path}": ${originalTokens} -> ${newTokens} tokens`, {
8405
+ color: 'magenta',
7803
8406
  });
7804
- // Check if we're now under budget
7805
- if (totalTokenCount <= maxTokens) {
7806
- logger.verbose(`Under token budget, stopping summarization.`, { color: 'green' });
7807
- break;
7808
- }
7809
- }
8407
+ })));
8408
+ logger.verbose(`Total token count after continuous queue: ${totalTokenCount}`, {
8409
+ color: totalTokenCount > maxTokens ? 'yellow' : 'green',
8410
+ });
7810
8411
  return { directories: results, totalTokenCount };
7811
8412
  }
8413
+ /**
8414
+ * Tiny semaphore mirroring `collectDiffs.createLimit` (kept private
8415
+ * here to avoid a cross-module import for one helper). Schedules at
8416
+ * most `maxConcurrent` operations concurrently; the rest queue FIFO.
8417
+ */
8418
+ function createLimit$1(maxConcurrent) {
8419
+ const limit = Math.max(1, maxConcurrent);
8420
+ let active = 0;
8421
+ const queue = [];
8422
+ const runNext = () => {
8423
+ active--;
8424
+ const next = queue.shift();
8425
+ if (next)
8426
+ next();
8427
+ };
8428
+ return async (operation) => {
8429
+ if (active >= limit) {
8430
+ await new Promise((resolve) => queue.push(resolve));
8431
+ }
8432
+ active++;
8433
+ try {
8434
+ return await operation();
8435
+ }
8436
+ finally {
8437
+ runNext();
8438
+ }
8439
+ };
8440
+ }
7812
8441
  /**
7813
8442
  * Summarize diffs using a three-phase approach:
7814
8443
  *
@@ -7822,7 +8451,16 @@ async function summarizeInWaves(directories, options) {
7822
8451
  * - Efficient parallel processing with predictable behavior
7823
8452
  * - Early exit when under token budget
7824
8453
  */
7825
- async function summarizeDiffs(rootDiffNode, { tokenizer, logger, maxTokens = 2048, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
8454
+ async function summarizeDiffs(rootDiffNode, { tokenizer, logger,
8455
+ // Default raised to 4096 (#845) so the budget matches the
8456
+ // canonical service configs in `langchain/utils.ts`. The
8457
+ // previous 2048 default came from an earlier era when 4k
8458
+ // context was a stretch for fast models; today every shipped
8459
+ // service overrides it to 4096 anyway. Keeping this in sync
8460
+ // with the service defaults means a caller that omits
8461
+ // `maxTokens` doesn't accidentally fall into a tighter budget
8462
+ // than the rest of the system assumes.
8463
+ maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
7826
8464
  // Calculate maxFileTokens as 25% of maxTokens if not specified
7827
8465
  const effectiveMaxFileTokens = maxFileTokens ?? Math.floor(maxTokens * 0.25);
7828
8466
  // PHASE 1: Directory grouping & assessment
@@ -10841,10 +11479,17 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
10841
11479
  // 1. Pre-process large files to prevent bias
10842
11480
  // 2. Group by directory and assess token count
10843
11481
  // 3. Wave-based parallel summarization until under budget
11482
+ //
11483
+ // The 4096 fallback (#845) matches the default service configs
11484
+ // for openai / anthropic / ollama (`langchain/utils.ts`). It's a
11485
+ // safety net for users with custom service definitions that omit
11486
+ // `tokenLimit` — without it those users hit a degenerate 2048
11487
+ // budget that triggers needless pre-summarization on diffs the
11488
+ // model could absorb whole.
10844
11489
  logger.startTimer();
10845
11490
  const summary = await summarizeDiffs(diffs, {
10846
11491
  tokenizer,
10847
- maxTokens: maxTokens || 2048,
11492
+ maxTokens: maxTokens || 4096,
10848
11493
  minTokensForSummary,
10849
11494
  maxFileTokens,
10850
11495
  maxConcurrent,
@@ -11794,6 +12439,164 @@ const CommitSplitPlanSchema = objectType({
11794
12439
  }))
11795
12440
  .min(1),
11796
12441
  });
12442
+
12443
+ const getGroupFiles$1 = (group) => group.files || [];
12444
+ const getGroupHunks$1 = (group) => group.hunks || [];
12445
+ function getPlanValidationIssues(plan, staged, hunkInventory) {
12446
+ const stagedFiles = new Set(staged.map((change) => change.filePath));
12447
+ const seen = new Set();
12448
+ const seenHunks = new Set();
12449
+ const unknownFiles = [];
12450
+ const duplicateFiles = [];
12451
+ const unknownHunks = [];
12452
+ const duplicateHunks = [];
12453
+ plan.groups.forEach((group) => {
12454
+ getGroupFiles$1(group).forEach((file) => {
12455
+ if (!stagedFiles.has(file)) {
12456
+ unknownFiles.push(file);
12457
+ return;
12458
+ }
12459
+ if (seen.has(file)) {
12460
+ duplicateFiles.push(file);
12461
+ return;
12462
+ }
12463
+ seen.add(file);
12464
+ });
12465
+ getGroupHunks$1(group).forEach((hunkId) => {
12466
+ const hunk = hunkInventory?.byId.get(hunkId);
12467
+ if (!hunk) {
12468
+ unknownHunks.push(hunkId);
12469
+ return;
12470
+ }
12471
+ if (seenHunks.has(hunkId)) {
12472
+ duplicateHunks.push(hunkId);
12473
+ return;
12474
+ }
12475
+ seenHunks.add(hunkId);
12476
+ });
12477
+ });
12478
+ const hunkCoveredFiles = new Set([...seenHunks].map((hunkId) => hunkInventory?.byId.get(hunkId)?.filePath));
12479
+ const mixedFiles = [...seen].filter((file) => hunkCoveredFiles.has(file));
12480
+ const partiallyCoveredFiles = [...hunkCoveredFiles]
12481
+ .filter((file) => Boolean(file))
12482
+ .filter((file) => {
12483
+ const fileHunks = hunkInventory?.byFile.get(file) || [];
12484
+ return fileHunks.some((hunk) => !seenHunks.has(hunk.id));
12485
+ });
12486
+ const missingFiles = [...stagedFiles].filter((file) => !seen.has(file) && !hunkCoveredFiles.has(file));
12487
+ return {
12488
+ unknownFiles,
12489
+ duplicateFiles,
12490
+ unknownHunks,
12491
+ duplicateHunks,
12492
+ mixedFiles,
12493
+ partiallyCoveredFiles,
12494
+ missingFiles,
12495
+ };
12496
+ }
12497
+ function hasPlanValidationIssues(issues) {
12498
+ return (issues.unknownFiles.length > 0 ||
12499
+ issues.duplicateFiles.length > 0 ||
12500
+ issues.unknownHunks.length > 0 ||
12501
+ issues.duplicateHunks.length > 0 ||
12502
+ issues.mixedFiles.length > 0 ||
12503
+ issues.partiallyCoveredFiles.length > 0 ||
12504
+ issues.missingFiles.length > 0);
12505
+ }
12506
+ function formatPlanValidationIssuesError(issues) {
12507
+ return [
12508
+ issues.unknownFiles.length ? `unknown files: ${issues.unknownFiles.join(', ')}` : undefined,
12509
+ issues.duplicateFiles.length
12510
+ ? `duplicate files: ${issues.duplicateFiles.join(', ')}`
12511
+ : undefined,
12512
+ issues.unknownHunks.length ? `unknown hunks: ${issues.unknownHunks.join(', ')}` : undefined,
12513
+ issues.duplicateHunks.length
12514
+ ? `duplicate hunks: ${issues.duplicateHunks.join(', ')}`
12515
+ : undefined,
12516
+ issues.mixedFiles.length
12517
+ ? `files assigned both as whole files and hunks: ${issues.mixedFiles.join(', ')}`
12518
+ : undefined,
12519
+ issues.partiallyCoveredFiles.length
12520
+ ? `files with only some hunks assigned: ${issues.partiallyCoveredFiles.join(', ')}`
12521
+ : undefined,
12522
+ issues.missingFiles.length ? `missing files: ${issues.missingFiles.join(', ')}` : undefined,
12523
+ ]
12524
+ .filter(Boolean)
12525
+ .join('; ');
12526
+ }
12527
+ function formatPlanValidationFeedback(issues) {
12528
+ const sections = [];
12529
+ if (issues.unknownFiles.length) {
12530
+ sections.push(`Files referenced that are NOT in the staged file inventory (remove or replace): ${issues.unknownFiles.join(', ')}`);
12531
+ }
12532
+ if (issues.duplicateFiles.length) {
12533
+ sections.push(`Files assigned to more than one group (each file may appear at most once): ${issues.duplicateFiles.join(', ')}`);
12534
+ }
12535
+ if (issues.unknownHunks.length) {
12536
+ sections.push(`Hunk IDs referenced that are NOT in the staged hunk inventory: ${issues.unknownHunks.join(', ')}`);
12537
+ }
12538
+ if (issues.duplicateHunks.length) {
12539
+ sections.push(`Hunk IDs assigned to more than one group (each hunk may appear at most once): ${issues.duplicateHunks.join(', ')}`);
12540
+ }
12541
+ if (issues.mixedFiles.length) {
12542
+ sections.push(`Files assigned BOTH as whole files and via hunks (pick one mode per file): ${issues.mixedFiles.join(', ')}`);
12543
+ }
12544
+ if (issues.partiallyCoveredFiles.length) {
12545
+ sections.push(`Files with only some hunks assigned (every hunk for these files must be covered): ${issues.partiallyCoveredFiles.join(', ')}`);
12546
+ }
12547
+ if (issues.missingFiles.length) {
12548
+ sections.push(`Staged files missing from every group (must appear exactly once): ${issues.missingFiles.join(', ')}`);
12549
+ }
12550
+ return sections.map((section) => `- ${section}`).join('\n');
12551
+ }
12552
+
12553
+ const NO_PREVIOUS_FEEDBACK_PLACEHOLDER = 'None — this is the first attempt.';
12554
+ const DEFAULT_MAX_PLAN_ATTEMPTS = 3;
12555
+ /**
12556
+ * Generate a commit-split plan with self-correcting retries on validator failures.
12557
+ *
12558
+ * The first attempt runs as normal. If `validatePlanForStagedFiles` rejects the result,
12559
+ * the validator's complaints are formatted as natural-language feedback and fed back
12560
+ * into the same prompt template (`previous_attempt_feedback` slot) so the model can
12561
+ * fix its own mistakes without re-running pre-processing.
12562
+ */
12563
+ async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged, hunkInventory, logger, tokenizer, metadata = {}, maxAttempts = DEFAULT_MAX_PLAN_ATTEMPTS, }) {
12564
+ let lastIssues = null;
12565
+ let attempt = 0;
12566
+ while (attempt < maxAttempts) {
12567
+ attempt++;
12568
+ const previousFeedback = lastIssues
12569
+ ? formatPlanValidationFeedback(lastIssues)
12570
+ : NO_PREVIOUS_FEEDBACK_PLACEHOLDER;
12571
+ const plan = await executeChainWithSchema(CommitSplitPlanSchema, llm, prompt, {
12572
+ ...variables,
12573
+ previous_attempt_feedback: previousFeedback,
12574
+ }, {
12575
+ logger,
12576
+ tokenizer,
12577
+ metadata: {
12578
+ task: 'commit-split-plan',
12579
+ ...metadata,
12580
+ planAttempt: attempt,
12581
+ },
12582
+ });
12583
+ const issues = getPlanValidationIssues(plan, staged, hunkInventory);
12584
+ if (!hasPlanValidationIssues(issues)) {
12585
+ if (attempt > 1 && logger) {
12586
+ logger.verbose(`Plan validated after ${attempt} attempts.`, { color: 'green' });
12587
+ }
12588
+ return { plan, attempts: attempt };
12589
+ }
12590
+ lastIssues = issues;
12591
+ if (logger) {
12592
+ logger.verbose(`Plan attempt ${attempt}/${maxAttempts} failed validation: ${formatPlanValidationIssuesError(issues)}`, { color: 'yellow' });
12593
+ }
12594
+ }
12595
+ throw new Error(lastIssues
12596
+ ? `Failed to produce a valid commit-split plan after ${maxAttempts} attempts. Final validator issues: ${formatPlanValidationIssuesError(lastIssues)}`
12597
+ : `Failed to produce a valid commit-split plan after ${maxAttempts} attempts.`);
12598
+ }
12599
+
11797
12600
  const COMMIT_SPLIT_PROMPT = prompts.PromptTemplate.fromTemplate(`You are helping split staged git changes into a small sequence of coherent commits.
11798
12601
 
11799
12602
  Return ONLY valid JSON matching this schema:
@@ -11810,14 +12613,13 @@ Return ONLY valid JSON matching this schema:
11810
12613
  }}
11811
12614
 
11812
12615
  Rules:
11813
- - Use each staged file exactly once.
11814
- - If a file has hunk IDs and contains unrelated changes, assign every hunk ID exactly once instead of assigning the whole file.
11815
- - Do not list the same file in "files" when assigning that file through "hunks".
11816
- - Only use file paths listed in the staged file inventory.
11817
- - Only use hunk IDs listed in the staged hunk inventory.
12616
+ - Every staged file MUST be assigned exactly once across all groups, either via "files" OR via every one of its hunk IDs (never both).
12617
+ - If you assign any hunk for a file, you MUST assign EVERY hunk for that file across the groups partial coverage is invalid.
12618
+ - Do not list the same file in "files" of more than one group, and do not assign the same hunk ID to more than one group.
12619
+ - Only use file paths listed in the staged file inventory. Do not invent files.
12620
+ - Only use hunk IDs listed in the staged hunk inventory. Do not invent hunk IDs.
11818
12621
  - Prefer 2-5 commits unless the changes are truly all one topic.
11819
12622
  - Keep commit titles concise and understandable.
11820
- - Do not invent files.
11821
12623
 
11822
12624
  Staged file inventory:
11823
12625
  {file_inventory}
@@ -11829,7 +12631,10 @@ Condensed staged diff:
11829
12631
  {summary}
11830
12632
 
11831
12633
  Additional context:
11832
- {additional_context}`);
12634
+ {additional_context}
12635
+
12636
+ Feedback on previous attempt (fix every item before responding):
12637
+ {previous_attempt_feedback}`);
11833
12638
  function isCommitSplitCommand(argv) {
11834
12639
  return Boolean(argv.split || argv.plan || argv.apply || argv._.includes('split'));
11835
12640
  }
@@ -11848,9 +12653,6 @@ function formatCommitSplitPlan(plan) {
11848
12653
  })
11849
12654
  .join('\n\n---\n\n');
11850
12655
  }
11851
- function getStagedFileSet(changes) {
11852
- return new Set(changes.map((change) => change.filePath));
11853
- }
11854
12656
  function getGroupFiles(group) {
11855
12657
  return group.files || [];
11856
12658
  }
@@ -11907,67 +12709,9 @@ function formatHunkInventory(inventory) {
11907
12709
  .join('\n');
11908
12710
  }
11909
12711
  function validatePlanForStagedFiles(plan, staged, hunkInventory) {
11910
- const stagedFiles = getStagedFileSet(staged);
11911
- const seen = new Set();
11912
- const seenHunks = new Set();
11913
- const unknown = [];
11914
- const duplicate = [];
11915
- const unknownHunks = [];
11916
- const duplicateHunks = [];
11917
- plan.groups.forEach((group) => {
11918
- getGroupFiles(group).forEach((file) => {
11919
- if (!stagedFiles.has(file)) {
11920
- unknown.push(file);
11921
- return;
11922
- }
11923
- if (seen.has(file)) {
11924
- duplicate.push(file);
11925
- return;
11926
- }
11927
- seen.add(file);
11928
- });
11929
- getGroupHunks(group).forEach((hunkId) => {
11930
- const hunk = hunkInventory?.byId.get(hunkId);
11931
- if (!hunk) {
11932
- unknownHunks.push(hunkId);
11933
- return;
11934
- }
11935
- if (seenHunks.has(hunkId)) {
11936
- duplicateHunks.push(hunkId);
11937
- return;
11938
- }
11939
- seenHunks.add(hunkId);
11940
- });
11941
- });
11942
- const hunkCoveredFiles = new Set([...seenHunks].map((hunkId) => hunkInventory?.byId.get(hunkId)?.filePath));
11943
- const mixedFiles = [...seen].filter((file) => hunkCoveredFiles.has(file));
11944
- const partiallyCoveredFiles = [...hunkCoveredFiles]
11945
- .filter((file) => Boolean(file))
11946
- .filter((file) => {
11947
- const fileHunks = hunkInventory?.byFile.get(file) || [];
11948
- return fileHunks.some((hunk) => !seenHunks.has(hunk.id));
11949
- });
11950
- const missing = [...stagedFiles].filter((file) => !seen.has(file) && !hunkCoveredFiles.has(file));
11951
- if (unknown.length ||
11952
- duplicate.length ||
11953
- unknownHunks.length ||
11954
- duplicateHunks.length ||
11955
- mixedFiles.length ||
11956
- partiallyCoveredFiles.length ||
11957
- missing.length) {
11958
- throw new Error([
11959
- unknown.length ? `unknown files: ${unknown.join(', ')}` : undefined,
11960
- duplicate.length ? `duplicate files: ${duplicate.join(', ')}` : undefined,
11961
- unknownHunks.length ? `unknown hunks: ${unknownHunks.join(', ')}` : undefined,
11962
- duplicateHunks.length ? `duplicate hunks: ${duplicateHunks.join(', ')}` : undefined,
11963
- mixedFiles.length ? `files assigned both as whole files and hunks: ${mixedFiles.join(', ')}` : undefined,
11964
- partiallyCoveredFiles.length
11965
- ? `files with only some hunks assigned: ${partiallyCoveredFiles.join(', ')}`
11966
- : undefined,
11967
- missing.length ? `missing files: ${missing.join(', ')}` : undefined,
11968
- ]
11969
- .filter(Boolean)
11970
- .join('; '));
12712
+ const issues = getPlanValidationIssues(plan, staged, hunkInventory);
12713
+ if (hasPlanValidationIssues(issues)) {
12714
+ throw new Error(formatPlanValidationIssuesError(issues));
11971
12715
  }
11972
12716
  }
11973
12717
  function assertNoUnstagedOverlap(plan, changes, hunkInventory) {
@@ -12071,22 +12815,26 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, })
12071
12815
  .map((change) => `- ${change.filePath}: ${change.status} - ${change.summary}`)
12072
12816
  .join('\n');
12073
12817
  const hunkInventoryText = formatHunkInventory(hunkInventory);
12074
- const plan = await executeChainWithSchema(CommitSplitPlanSchema, llm, COMMIT_SPLIT_PROMPT, {
12075
- file_inventory: fileInventory,
12076
- hunk_inventory: hunkInventoryText,
12077
- summary,
12078
- additional_context: argv.additional || '',
12079
- }, {
12818
+ const { plan } = await generateValidatedCommitSplitPlan({
12819
+ llm,
12820
+ prompt: COMMIT_SPLIT_PROMPT,
12821
+ variables: {
12822
+ file_inventory: fileInventory,
12823
+ hunk_inventory: hunkInventoryText,
12824
+ summary,
12825
+ additional_context: argv.additional || '',
12826
+ },
12827
+ staged: changes.staged,
12828
+ hunkInventory,
12080
12829
  logger,
12081
12830
  tokenizer,
12082
12831
  metadata: {
12083
- task: 'commit-split-plan',
12084
12832
  command: 'commit',
12085
12833
  provider: config.service.provider,
12086
12834
  model: String(config.service.model),
12087
12835
  },
12836
+ maxAttempts: DEFAULT_MAX_PLAN_ATTEMPTS,
12088
12837
  });
12089
- validatePlanForStagedFiles(plan, changes.staged, hunkInventory);
12090
12838
  if (argv.apply) {
12091
12839
  return await applyCommitSplitPlan({
12092
12840
  plan,
@@ -29260,6 +30008,7 @@ y.command(init.command, init.desc, init.builder, init.handler);
29260
30008
  y.command(doctor.command, doctor.desc, doctor.builder, doctor.handler);
29261
30009
  y.command(log.command, log.desc, log.builder, log.handler);
29262
30010
  y.command(ui.command, ui.desc, ui.builder, ui.handler);
30011
+ y.command(cache.command, cache.desc, cache.builder, cache.handler);
29263
30012
  y.help().parse(process.argv.slice(2));
29264
30013
 
29265
30014
  /**
@@ -29711,6 +30460,7 @@ var commitValidationHandler = /*#__PURE__*/Object.freeze({
29711
30460
  handleValidationErrors: handleValidationErrors
29712
30461
  });
29713
30462
 
30463
+ exports.cache = cache;
29714
30464
  exports.changelog = changelog;
29715
30465
  exports.commit = commit;
29716
30466
  exports.doctor = doctor;