git-coco 0.41.2 → 0.43.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,6 +14,9 @@ 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');
@@ -37,10 +41,6 @@ require('@langchain/core/utils/async_caller');
37
41
  var tiktoken = require('tiktoken');
38
42
  var child_process = require('child_process');
39
43
  var node_child_process = require('node:child_process');
40
- var fs$1 = require('node:fs');
41
- var os$1 = require('node:os');
42
- var path$1 = require('node:path');
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
72
  var os__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(os$1);
72
73
  var path__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(path$1);
73
- var crypto__namespace = /*#__PURE__*/_interopNamespaceDefault(crypto);
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.41.2";
81
+ const BUILD_VERSION = "0.43.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 = (_) => { };
@@ -6368,6 +6677,7 @@ function resolveDynamicService(config, task) {
6368
6677
  };
6369
6678
  }
6370
6679
 
6680
+ const benchCalls = [];
6371
6681
  const telemetryByCommand = new Map();
6372
6682
  function estimatePromptTokens(tokenizer, renderedPrompt) {
6373
6683
  if (!tokenizer)
@@ -6379,10 +6689,28 @@ function estimatePromptTokens(tokenizer, renderedPrompt) {
6379
6689
  return undefined;
6380
6690
  }
6381
6691
  }
6692
+ function isBenchModeActive() {
6693
+ return Boolean(process.env.COCO_BENCH && process.env.COCO_BENCH !== '0');
6694
+ }
6695
+ function recordBenchCall(metadata) {
6696
+ if (!isBenchModeActive())
6697
+ return;
6698
+ benchCalls.push({
6699
+ task: metadata.task,
6700
+ command: metadata.command,
6701
+ provider: metadata.provider,
6702
+ model: metadata.model,
6703
+ promptTokens: metadata.promptTokens,
6704
+ elapsedMs: metadata.elapsedMs,
6705
+ inputDocuments: metadata.inputDocuments,
6706
+ inputChunks: metadata.inputChunks,
6707
+ });
6708
+ }
6382
6709
  function logLlmCall(logger, metadata) {
6383
6710
  if (!logger)
6384
6711
  return;
6385
6712
  recordLlmTelemetry(metadata);
6713
+ recordBenchCall(metadata);
6386
6714
  const fields = [
6387
6715
  `task=${metadata.task}`,
6388
6716
  metadata.command ? `command=${metadata.command}` : undefined,
@@ -7511,6 +7839,56 @@ function getPathFromFilePath(filePath) {
7511
7839
  return filePath.split('/').slice(0, -1).join('/');
7512
7840
  }
7513
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
+ }
7514
7892
  async function summarize(documents$1, { chain, textSplitter, options, logger, tokenizer, metadata }) {
7515
7893
  const { returnIntermediateSteps = false } = options || {};
7516
7894
  const docs = await textSplitter.splitDocuments(documents$1.map((doc) => new documents.Document(doc)));
@@ -7518,10 +7896,10 @@ async function summarize(documents$1, { chain, textSplitter, options, logger, to
7518
7896
  ? docs.reduce((sum, doc) => sum + tokenizer(doc.pageContent), 0)
7519
7897
  : undefined;
7520
7898
  const startedAt = Date.now();
7521
- const res = await chain.invoke({
7899
+ const res = await invokeWithBackoff(chain, {
7522
7900
  input_documents: docs,
7523
7901
  returnIntermediateSteps,
7524
- });
7902
+ }, logger);
7525
7903
  const elapsedMs = Date.now() - startedAt;
7526
7904
  logLlmCall(logger, {
7527
7905
  task: 'summarize',
@@ -7536,10 +7914,175 @@ async function summarize(documents$1, { chain, textSplitter, options, logger, to
7536
7914
  return res.text && res.text.trim();
7537
7915
  }
7538
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
+ }
7539
8043
  /**
7540
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.
7541
8053
  */
7542
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
+ }
7543
8086
  try {
7544
8087
  const fileSummary = await summarize([
7545
8088
  {
@@ -7563,6 +8106,13 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
7563
8106
  },
7564
8107
  });
7565
8108
  const newTokenCount = tokenizer(fileSummary);
8109
+ if (cacheKey && cacheModel) {
8110
+ writeDiffSummary(cacheRepo, cacheKey, {
8111
+ summary: fileSummary,
8112
+ model: cacheModel,
8113
+ tokens: newTokenCount,
8114
+ });
8115
+ }
7566
8116
  return {
7567
8117
  ...fileDiff,
7568
8118
  diff: fileSummary,
@@ -7576,16 +8126,41 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
7576
8126
  }
7577
8127
  }
7578
8128
  /**
7579
- * 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.
7580
8137
  */
7581
8138
  async function processInWaves$1(items, processor, maxConcurrent) {
7582
- const results = [];
7583
- for (let i = 0; i < items.length; i += maxConcurrent) {
7584
- const wave = items.slice(i, i + maxConcurrent);
7585
- const waveResults = await Promise.all(wave.map(processor));
7586
- results.push(...waveResults);
7587
- }
7588
- 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
+ };
7589
8164
  }
7590
8165
  /**
7591
8166
  * Pre-summarize individual files that exceed the maxFileTokens threshold.
@@ -7650,6 +8225,13 @@ async function preprocessLargeFiles(rootNode, options) {
7650
8225
  return rebuildNode(rootNode);
7651
8226
  }
7652
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
+ }
7653
8235
  /**
7654
8236
  * Create groups from a given node info.
7655
8237
  * @param {DiffNode} node - The node info to start grouping.
@@ -7675,6 +8257,32 @@ function createDirectoryDiffs(node) {
7675
8257
  * Summarize a directory diff asynchronously.
7676
8258
  */
7677
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
+ }
7678
8286
  try {
7679
8287
  const directorySummary = await summarize(directory.diffs.map((diff) => ({
7680
8288
  pageContent: diff.diff,
@@ -7696,6 +8304,13 @@ async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenize
7696
8304
  },
7697
8305
  });
7698
8306
  const newTokenTotal = tokenizer(directorySummary);
8307
+ if (cacheKey && cacheModel) {
8308
+ writeDiffSummary(cacheRepo, cacheKey, {
8309
+ summary: directorySummary,
8310
+ model: cacheModel,
8311
+ tokens: newTokenTotal,
8312
+ });
8313
+ }
7699
8314
  return {
7700
8315
  diffs: directory.diffs,
7701
8316
  path: directory.path,
@@ -7730,66 +8345,99 @@ const defaultOutputCallback = (group) => {
7730
8345
  return output;
7731
8346
  };
7732
8347
  /**
7733
- * Process directory summarization in waves to respect concurrency limits
7734
- * 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.
7735
8370
  */
7736
8371
  async function summarizeInWaves(directories, options) {
7737
8372
  const { totalTokenCount: initialTotal, maxTokens, minTokensForSummary, maxConcurrent, logger, chain, textSplitter, tokenizer, metadata, } = options;
7738
8373
  let totalTokenCount = initialTotal;
7739
8374
  const results = [...directories];
7740
- // Create sorted indices by token count (descending) for prioritized processing
7741
- const sortedIndices = directories
8375
+ // Pick eligible directories upfront, sorted big-first.
8376
+ const eligibleIndices = directories
7742
8377
  .map((d, i) => ({ index: i, tokens: d.tokenCount }))
7743
- .sort((a, b) => b.tokens - a.tokens);
7744
- let cursor = 0;
7745
- while (totalTokenCount > maxTokens && cursor < sortedIndices.length) {
7746
- // Select wave candidates: directories that exceed minTokensForSummary
7747
- const wave = [];
7748
- for (let i = cursor; i < sortedIndices.length && wave.length < maxConcurrent; i++) {
7749
- const { index, tokens } = sortedIndices[i];
7750
- // Skip directories below the minimum threshold
7751
- if (tokens < minTokensForSummary) {
7752
- cursor = i + 1;
7753
- continue;
7754
- }
7755
- // Skip directories that have already been summarized
7756
- if (results[index].summary) {
7757
- cursor = i + 1;
7758
- continue;
7759
- }
7760
- wave.push(index);
7761
- cursor = i + 1;
7762
- }
7763
- // No more eligible candidates
7764
- if (wave.length === 0) {
7765
- 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;
7766
8392
  }
7767
- logger.verbose(`\nProcessing wave of ${wave.length} directories...`, { color: 'blue' });
7768
- // Process wave in parallel
7769
- const waveResults = await Promise.all(wave.map((idx) => summarizeDirectoryDiff(results[idx], { chain, textSplitter, tokenizer, logger, metadata })));
7770
- // Update results and recalculate total
7771
- waveResults.forEach((result, i) => {
7772
- const idx = wave[i];
7773
- const originalTokens = results[idx].tokenCount;
7774
- const newTokens = result.tokenCount;
7775
- const reduction = originalTokens - newTokens;
7776
- totalTokenCount -= reduction;
7777
- results[idx] = result;
7778
- logger.verbose(` • Summarized "/${result.path}": ${originalTokens} -> ${newTokens} tokens`, {
7779
- color: 'magenta',
7780
- });
8393
+ const result = await summarizeDirectoryDiff(results[idx], {
8394
+ chain,
8395
+ textSplitter,
8396
+ tokenizer,
8397
+ logger,
8398
+ metadata,
7781
8399
  });
7782
- logger.verbose(`Total token count: ${totalTokenCount}`, {
7783
- 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',
7784
8406
  });
7785
- // Check if we're now under budget
7786
- if (totalTokenCount <= maxTokens) {
7787
- logger.verbose(`Under token budget, stopping summarization.`, { color: 'green' });
7788
- break;
7789
- }
7790
- }
8407
+ })));
8408
+ logger.verbose(`Total token count after continuous queue: ${totalTokenCount}`, {
8409
+ color: totalTokenCount > maxTokens ? 'yellow' : 'green',
8410
+ });
7791
8411
  return { directories: results, totalTokenCount };
7792
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
+ }
7793
8441
  /**
7794
8442
  * Summarize diffs using a three-phase approach:
7795
8443
  *
@@ -7803,7 +8451,16 @@ async function summarizeInWaves(directories, options) {
7803
8451
  * - Efficient parallel processing with predictable behavior
7804
8452
  * - Early exit when under token budget
7805
8453
  */
7806
- 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, }) {
7807
8464
  // Calculate maxFileTokens as 25% of maxTokens if not specified
7808
8465
  const effectiveMaxFileTokens = maxFileTokens ?? Math.floor(maxTokens * 0.25);
7809
8466
  // PHASE 1: Directory grouping & assessment
@@ -10822,10 +11479,17 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
10822
11479
  // 1. Pre-process large files to prevent bias
10823
11480
  // 2. Group by directory and assess token count
10824
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.
10825
11489
  logger.startTimer();
10826
11490
  const summary = await summarizeDiffs(diffs, {
10827
11491
  tokenizer,
10828
- maxTokens: maxTokens || 2048,
11492
+ maxTokens: maxTokens || 4096,
10829
11493
  minTokensForSummary,
10830
11494
  maxFileTokens,
10831
11495
  maxConcurrent,
@@ -15394,6 +16058,13 @@ const LOG_INK_KEY_BINDINGS = [
15394
16058
  description: 'Push the dedicated pull-request action panel for the current branch.',
15395
16059
  contexts: ['normal'],
15396
16060
  },
16061
+ {
16062
+ id: 'navigateConflicts',
16063
+ keys: ['gx'],
16064
+ label: 'conflicts',
16065
+ description: 'Push the conflict resolution helper view (available during merge/rebase/cherry-pick/revert).',
16066
+ contexts: ['normal'],
16067
+ },
15397
16068
  {
15398
16069
  id: 'navigateBack',
15399
16070
  keys: ['<', 'esc'],
@@ -15523,6 +16194,7 @@ const GLOBAL_BINDING_IDS = [
15523
16194
  'navigateStash',
15524
16195
  'navigateWorktrees',
15525
16196
  'navigatePullRequest',
16197
+ 'navigateConflicts',
15526
16198
  'navigateBack',
15527
16199
  ];
15528
16200
  const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
@@ -15695,6 +16367,12 @@ function getLogInkFooterHints(options) {
15695
16367
  global: NORMAL_GLOBAL_HINTS,
15696
16368
  };
15697
16369
  }
16370
+ if (options.activeView === 'conflicts') {
16371
+ return {
16372
+ contextual: ['↑/↓ files', 'enter diff', 's stage', 'u theirs', 'U ours', 'o edit', 'C continue*', 'esc back'],
16373
+ global: NORMAL_GLOBAL_HINTS,
16374
+ };
16375
+ }
15698
16376
  return {
15699
16377
  // History view default hints. Mutating ops (`c` cherry-pick, `R`
15700
16378
  // revert, `Z` reset, `i` interactive-rebase) all route through a
@@ -16441,6 +17119,7 @@ function createLogInkState(rows, options = {}) {
16441
17119
  selectedTagIndex: 0,
16442
17120
  selectedStashIndex: 0,
16443
17121
  selectedWorktreeListIndex: 0,
17122
+ selectedConflictFileIndex: 0,
16444
17123
  branchSort: DEFAULT_BRANCH_SORT_MODE,
16445
17124
  tagSort: DEFAULT_TAG_SORT_MODE,
16446
17125
  paletteFilter: '',
@@ -16694,6 +17373,12 @@ function applyLogInkAction(state, action) {
16694
17373
  selectedWorktreeListIndex: clampIndex$1(state.selectedWorktreeListIndex + action.delta, action.count),
16695
17374
  pendingKey: undefined,
16696
17375
  };
17376
+ case 'moveConflictFile':
17377
+ return {
17378
+ ...state,
17379
+ selectedConflictFileIndex: clampIndex$1(state.selectedConflictFileIndex + action.delta, action.count),
17380
+ pendingKey: undefined,
17381
+ };
16697
17382
  case 'cycleBranchSort':
16698
17383
  return {
16699
17384
  ...state,
@@ -17362,6 +18047,8 @@ function getLogInkPaletteExecuteEvents(command, state) {
17362
18047
  return [action({ type: 'pushView', value: 'worktrees' })];
17363
18048
  case 'navigatePullRequest':
17364
18049
  return [action({ type: 'pushView', value: 'pull-request' })];
18050
+ case 'navigateConflicts':
18051
+ return [action({ type: 'pushView', value: 'conflicts' })];
17365
18052
  case 'navigateBack':
17366
18053
  return [action({ type: 'popView' })];
17367
18054
  case 'openSelected': {
@@ -17833,6 +18520,12 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
17833
18520
  action({ type: 'setStatus', value: 'jumped to pull request' }),
17834
18521
  ];
17835
18522
  }
18523
+ if (state.pendingKey === 'g' && inputValue === 'x') {
18524
+ return [
18525
+ action({ type: 'pushView', value: 'conflicts' }),
18526
+ action({ type: 'setStatus', value: 'jumped to conflicts' }),
18527
+ ];
18528
+ }
17836
18529
  // `gH` chord: apply the cursored hunk to the index (`git apply
17837
18530
  // --cached`). Sibling of bare `H` which targets the worktree.
17838
18531
  // Discoverable via the footer hint on diff views and the help
@@ -18142,6 +18835,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18142
18835
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
18143
18836
  return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
18144
18837
  }
18838
+ if (state.activeView === 'conflicts' && context.conflictFileCount) {
18839
+ return [action({ type: 'moveConflictFile', delta: -1, count: context.conflictFileCount })];
18840
+ }
18145
18841
  if (state.activeView === 'history' &&
18146
18842
  state.focus === 'commits' &&
18147
18843
  state.selectedIndex === 0 &&
@@ -18220,6 +18916,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18220
18916
  if (isWorktreeActionTarget(state) && context.worktreeListCount) {
18221
18917
  return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
18222
18918
  }
18919
+ if (state.activeView === 'conflicts' && context.conflictFileCount) {
18920
+ return [action({ type: 'moveConflictFile', delta: 1, count: context.conflictFileCount })];
18921
+ }
18223
18922
  return [
18224
18923
  action(state.focus === 'sidebar'
18225
18924
  ? { type: 'nextSidebarTab' }
@@ -18405,6 +19104,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18405
19104
  fileIndex: state.selectedWorktreeFileIndex,
18406
19105
  })];
18407
19106
  }
19107
+ // Enter on a conflict file opens the worktree diff for that file so
19108
+ // the user can inspect the conflict markers in context.
19109
+ if (key.return && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
19110
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-open-diff', payload: context.conflictSelectedPath }];
19111
+ }
18408
19112
  // Enter on a branch row checks the branch out. Non-destructive workflow
18409
19113
  // action — no confirmation prompt. Fires from either the dedicated
18410
19114
  // branches view or from the sidebar when the branches tab is focused
@@ -18546,6 +19250,32 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18546
19250
  if (inputValue === 'o' && state.activeView === 'diff' && state.diffSource === 'stash' && context.stashDiffSelectedPath) {
18547
19251
  return [{ type: 'openFileInEditor', path: context.stashDiffSelectedPath }];
18548
19252
  }
19253
+ // --- Conflicts view per-row handlers ---
19254
+ // `o` opens the conflicted file in $EDITOR for manual resolution.
19255
+ if (inputValue === 'o' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
19256
+ return [{ type: 'openFileInEditor', path: context.conflictSelectedPath }];
19257
+ }
19258
+ // `s` stages the conflicted file (marks it resolved).
19259
+ if (inputValue === 's' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
19260
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-stage', payload: context.conflictSelectedPath }];
19261
+ }
19262
+ // `u` resolves by keeping theirs (incoming changes).
19263
+ if (inputValue === 'u' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
19264
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-theirs', payload: context.conflictSelectedPath }];
19265
+ }
19266
+ // `U` resolves by keeping ours (current branch).
19267
+ if (inputValue === 'U' && state.activeView === 'conflicts' && context.conflictFileCount && context.conflictSelectedPath) {
19268
+ return [{ type: 'runWorkflowAction', id: 'resolve-conflict-ours', payload: context.conflictSelectedPath }];
19269
+ }
19270
+ // `C` continues the in-progress operation (available when no conflicts remain).
19271
+ if (inputValue === 'C' && state.activeView === 'conflicts' && context.conflictFileCount === 0) {
19272
+ return [{ type: 'runWorkflowAction', id: 'continue-operation' }];
19273
+ }
19274
+ // Always intercept `C` on the conflicts view to prevent fallthrough to
19275
+ // the global `C` (Create PR) binding when conflicts remain.
19276
+ if (inputValue === 'C' && state.activeView === 'conflicts') {
19277
+ return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })];
19278
+ }
18549
19279
  // `c` on a stash diff cherry-picks the file under the cursor —
18550
19280
  // materializes that single path from the stash into the working tree
18551
19281
  // (`git checkout <stashRef> -- <path>`). Routed through the y-confirm
@@ -21842,6 +22572,21 @@ function skipOperation(git, operation) {
21842
22572
  }
21843
22573
  return runAction(() => git.raw(command.args), command.successMessage);
21844
22574
  }
22575
+ function resolveConflictOurs(git, path) {
22576
+ return runAction(async () => {
22577
+ await git.raw(['checkout', '--ours', '--', path]);
22578
+ await git.raw(['add', '--', path]);
22579
+ }, `Resolved ${path} (kept ours)`);
22580
+ }
22581
+ function resolveConflictTheirs(git, path) {
22582
+ return runAction(async () => {
22583
+ await git.raw(['checkout', '--theirs', '--', path]);
22584
+ await git.raw(['add', '--', path]);
22585
+ }, `Resolved ${path} (kept theirs)`);
22586
+ }
22587
+ function stageConflictResolved(git, path) {
22588
+ return runAction(() => git.raw(['add', '--', path]), `Staged ${path} (marked resolved)`);
22589
+ }
21845
22590
 
21846
22591
  function openProviderUrl(repository, target, openUrl = defaultOpenUrlRunner) {
21847
22592
  const url = repository ? buildProviderUrl(repository, target) : undefined;
@@ -25224,6 +25969,51 @@ function LogInkApp(deps) {
25224
25969
  }
25225
25970
  return abortOperation(git, operation);
25226
25971
  },
25972
+ 'resolve-conflict-ours': async () => {
25973
+ const path = payload?.trim();
25974
+ if (!path)
25975
+ return { ok: false, message: 'No conflict file selected' };
25976
+ return resolveConflictOurs(git, path);
25977
+ },
25978
+ 'resolve-conflict-theirs': async () => {
25979
+ const path = payload?.trim();
25980
+ if (!path)
25981
+ return { ok: false, message: 'No conflict file selected' };
25982
+ return resolveConflictTheirs(git, path);
25983
+ },
25984
+ 'resolve-conflict-stage': async () => {
25985
+ const path = payload?.trim();
25986
+ if (!path)
25987
+ return { ok: false, message: 'No conflict file selected' };
25988
+ return stageConflictResolved(git, path);
25989
+ },
25990
+ 'resolve-conflict-open-diff': async () => {
25991
+ // Push the diff view for the conflicted file so the user can
25992
+ // inspect conflict markers in context. We find the file's index
25993
+ // in the worktree file list and navigate to its diff.
25994
+ const path = payload?.trim();
25995
+ if (!path)
25996
+ return { ok: false, message: 'No conflict file selected' };
25997
+ const worktreeFiles = context.worktree?.files || [];
25998
+ const fileIndex = worktreeFiles.findIndex((f) => f.path === path);
25999
+ if (fileIndex >= 0) {
26000
+ dispatch({ type: 'navigateOpenDiffForWorktreeFile', fileIndex });
26001
+ return { ok: true, message: `Viewing diff for ${path}` };
26002
+ }
26003
+ // File not in worktree list (e.g. deleted-by-us) — open in
26004
+ // editor as fallback so the user can still inspect it.
26005
+ return { ok: true, message: `${path} not in worktree diff list` };
26006
+ },
26007
+ 'continue-operation': async () => {
26008
+ const operation = context.operation?.operation;
26009
+ if (!operation || operation === 'none') {
26010
+ return { ok: false, message: 'No git operation in progress' };
26011
+ }
26012
+ if ((context.operation?.conflictedFiles.length ?? 0) > 0) {
26013
+ return { ok: false, message: 'Resolve all conflicts before continuing' };
26014
+ }
26015
+ return continueOperation(git, operation);
26016
+ },
25227
26017
  'open-pr': async () => {
25228
26018
  const repo = context.provider?.repository;
25229
26019
  if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
@@ -25747,6 +26537,14 @@ function LogInkApp(deps) {
25747
26537
  ? selected?.hash
25748
26538
  : undefined,
25749
26539
  worktreeDirty,
26540
+ conflictFileCount: context.operation?.conflictedFiles.length,
26541
+ conflictSelectedPath: (() => {
26542
+ const files = context.operation?.conflictedFiles;
26543
+ if (!files || files.length === 0)
26544
+ return undefined;
26545
+ const clamped = Math.min(state.selectedConflictFileIndex, files.length - 1);
26546
+ return files[clamped]?.path;
26547
+ })(),
25750
26548
  // H / gH need the actual diff text (not just hunk offsets) to
25751
26549
  // slice the cursored hunk into a `git apply` patch. Stash uses
25752
26550
  // the full `git stash show -p` output; commit-diff uses the
@@ -26111,6 +26909,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
26111
26909
  if (state.activeView === 'pull-request') {
26112
26910
  return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
26113
26911
  }
26912
+ if (state.activeView === 'conflicts') {
26913
+ return renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
26914
+ }
26114
26915
  return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
26115
26916
  }
26116
26917
  function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
@@ -26294,6 +27095,79 @@ function buildStatusSurfaceRows(groups) {
26294
27095
  }
26295
27096
  return rows;
26296
27097
  }
27098
+ function renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
27099
+ const { Box, Text } = components;
27100
+ const focused = state.focus === 'commits';
27101
+ const loading = isLogInkContextKeyLoading(contextStatus, 'operation');
27102
+ const operation = context.operation;
27103
+ const conflictedFiles = operation?.conflictedFiles || [];
27104
+ const operationType = operation?.operation || 'none';
27105
+ // If no operation is in progress, show a fallback message.
27106
+ if (!loading && operationType === 'none') {
27107
+ return h(Box, {
27108
+ borderColor: focusBorderColor(theme, focused),
27109
+ borderStyle: theme.borderStyle,
27110
+ flexDirection: 'column',
27111
+ flexShrink: 0,
27112
+ paddingX: 1,
27113
+ width,
27114
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Conflicts', focused)), h(Text, { dimColor: true }, 'no operation in progress')), h(Text, { key: 'conflicts-empty', dimColor: true }, 'No merge, rebase, cherry-pick, or revert in progress.'));
27115
+ }
27116
+ // All conflicts resolved — show the "continue" hint.
27117
+ if (!loading && conflictedFiles.length === 0 && operationType !== 'none') {
27118
+ return h(Box, {
27119
+ borderColor: focusBorderColor(theme, focused),
27120
+ borderStyle: theme.borderStyle,
27121
+ flexDirection: 'column',
27122
+ flexShrink: 0,
27123
+ paddingX: 1,
27124
+ width,
27125
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Conflicts', focused)), h(Text, { dimColor: true }, `${operationType} — all conflicts resolved`)), h(Text, { key: 'conflicts-hint', dimColor: true }, `All conflicts resolved. Press C to continue the ${operationType}, or < to go back.`));
27126
+ }
27127
+ const selected = Math.max(0, Math.min(state.selectedConflictFileIndex, Math.max(0, conflictedFiles.length - 1)));
27128
+ const listRows = Math.max(4, bodyRows - 4);
27129
+ const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
27130
+ const visible = conflictedFiles.slice(startIndex, startIndex + listRows);
27131
+ const remaining = conflictedFiles.length;
27132
+ const headerRight = loading
27133
+ ? 'loading conflicts'
27134
+ : `${operationType} — ${remaining} ${remaining === 1 ? 'conflict' : 'conflicts'} remaining`;
27135
+ const statusLabel = (file) => {
27136
+ const code = `${file.indexStatus}${file.worktreeStatus}`;
27137
+ switch (code) {
27138
+ case 'UU': return 'both modified';
27139
+ case 'AA': return 'added by both';
27140
+ case 'DD': return 'both deleted';
27141
+ case 'AU':
27142
+ case 'UA': return 'added by one';
27143
+ case 'DU': return 'deleted by us';
27144
+ case 'UD': return 'deleted by them';
27145
+ default: return code;
27146
+ }
27147
+ };
27148
+ const lines = loading
27149
+ ? [h(Text, { key: 'conflicts-loading', dimColor: true }, formatLogInkLoading({ resource: 'conflicts' }))]
27150
+ : visible.map((file, offset) => {
27151
+ const index = startIndex + offset;
27152
+ const isSelected = index === selected;
27153
+ const cursor = isSelected ? '>' : ' ';
27154
+ const code = `${file.indexStatus}${file.worktreeStatus}`;
27155
+ const label = statusLabel(file);
27156
+ return h(Text, {
27157
+ key: `conflict-${index}`,
27158
+ bold: isSelected,
27159
+ dimColor: !isSelected,
27160
+ }, truncate$1(`${cursor} ${code} ${file.path} (${label})`, width - 4));
27161
+ });
27162
+ return h(Box, {
27163
+ borderColor: focusBorderColor(theme, focused),
27164
+ borderStyle: theme.borderStyle,
27165
+ flexDirection: 'column',
27166
+ flexShrink: 0,
27167
+ paddingX: 1,
27168
+ width,
27169
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Conflicts', focused)), h(Text, { dimColor: true }, headerRight)), ...lines);
27170
+ }
26297
27171
  function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
26298
27172
  const { Box, Text } = components;
26299
27173
  const focused = state.focus === 'commits';
@@ -29031,6 +29905,7 @@ y.command(init.command, init.desc, init.builder, init.handler);
29031
29905
  y.command(doctor.command, doctor.desc, doctor.builder, doctor.handler);
29032
29906
  y.command(log.command, log.desc, log.builder, log.handler);
29033
29907
  y.command(ui.command, ui.desc, ui.builder, ui.handler);
29908
+ y.command(cache.command, cache.desc, cache.builder, cache.handler);
29034
29909
  y.help().parse(process.argv.slice(2));
29035
29910
 
29036
29911
  /**
@@ -29482,6 +30357,7 @@ var commitValidationHandler = /*#__PURE__*/Object.freeze({
29482
30357
  handleValidationErrors: handleValidationErrors
29483
30358
  });
29484
30359
 
30360
+ exports.cache = cache;
29485
30361
  exports.changelog = changelog;
29486
30362
  exports.commit = commit;
29487
30363
  exports.doctor = doctor;