git-coco 0.42.0 → 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.d.ts +15 -2
- package/dist/index.esm.mjs +718 -71
- package/dist/index.js +719 -72
- package/package.json +1 -1
package/dist/index.esm.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import yargs from 'yargs';
|
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import * as fs from 'fs';
|
|
7
7
|
import fs__default, { promises, existsSync, readFileSync, readdirSync } from 'fs';
|
|
8
|
+
import * as crypto from 'node:crypto';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
8
10
|
import * as ini from 'ini';
|
|
9
11
|
import * as os from 'os';
|
|
10
12
|
import os__default, { tmpdir } from 'os';
|
|
@@ -14,11 +16,12 @@ import Ajv from 'ajv';
|
|
|
14
16
|
import ora from 'ora';
|
|
15
17
|
import now from 'performance-now';
|
|
16
18
|
import prettyMilliseconds from 'pretty-ms';
|
|
19
|
+
import * as fs$1 from 'node:fs';
|
|
20
|
+
import * as os$1 from 'node:os';
|
|
21
|
+
import * as path$1 from 'node:path';
|
|
17
22
|
import { ChatAnthropic } from '@langchain/anthropic';
|
|
18
23
|
import { ChatOllama } from '@langchain/ollama';
|
|
19
24
|
import { ChatOpenAI } from '@langchain/openai';
|
|
20
|
-
import * as fs$1 from 'node:fs';
|
|
21
|
-
import * as path$1 from 'node:path';
|
|
22
25
|
import { StructuredOutputParser, BaseOutputParser, StringOutputParser } from '@langchain/core/output_parsers';
|
|
23
26
|
import { minimatch } from 'minimatch';
|
|
24
27
|
import { simpleGit, GitError } from 'simple-git';
|
|
@@ -40,8 +43,6 @@ import '@langchain/core/utils/async_caller';
|
|
|
40
43
|
import { encoding_for_model } from 'tiktoken';
|
|
41
44
|
import { spawn, exec, execFile } from 'child_process';
|
|
42
45
|
import { spawnSync } from 'node:child_process';
|
|
43
|
-
import * as os$1 from 'node:os';
|
|
44
|
-
import * as crypto from 'node:crypto';
|
|
45
46
|
import * as readline from 'readline';
|
|
46
47
|
import readline__default from 'readline';
|
|
47
48
|
import { promisify } from 'util';
|
|
@@ -53,7 +54,7 @@ import { pathToFileURL } from 'url';
|
|
|
53
54
|
/**
|
|
54
55
|
* Current build version from package.json
|
|
55
56
|
*/
|
|
56
|
-
const BUILD_VERSION = "0.
|
|
57
|
+
const BUILD_VERSION = "0.43.0";
|
|
57
58
|
|
|
58
59
|
const isInteractive = (config) => {
|
|
59
60
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -229,6 +230,19 @@ const SUMMARIZE_PROMPT = new PromptTemplate({
|
|
|
229
230
|
inputVariables: inputVariables$4,
|
|
230
231
|
template: template$4,
|
|
231
232
|
});
|
|
233
|
+
/**
|
|
234
|
+
* Stable fingerprint of the active summarization template (#845, PR 5).
|
|
235
|
+
*
|
|
236
|
+
* The diff-summary cache keys include this hash so any prompt edit
|
|
237
|
+
* invalidates prior cache entries automatically — no manual bumps,
|
|
238
|
+
* no stale outputs that no longer reflect the current prompt's voice
|
|
239
|
+
* or rules. Only the template body matters; whitespace differences
|
|
240
|
+
* still re-key the cache, which is the safe default.
|
|
241
|
+
*/
|
|
242
|
+
const SUMMARIZE_PROMPT_HASH = createHash('sha256')
|
|
243
|
+
.update(template$4)
|
|
244
|
+
.digest('hex')
|
|
245
|
+
.slice(0, 16);
|
|
232
246
|
|
|
233
247
|
/**
|
|
234
248
|
* Base class for all LangChain-related errors
|
|
@@ -412,10 +426,21 @@ function getDefaultServiceApiKey(config) {
|
|
|
412
426
|
}
|
|
413
427
|
const DEFAULT_OPENAI_LLM_SERVICE = {
|
|
414
428
|
provider: 'openai',
|
|
415
|
-
|
|
429
|
+
// Bumped from `gpt-4o-mini` to `gpt-4.1-nano` (#854). Diff
|
|
430
|
+
// condensing is bounded summarization — the cheaper / faster
|
|
431
|
+
// tier is the right default for it; quality is on par for this
|
|
432
|
+
// class of task. Users who want the older 4o-mini can still
|
|
433
|
+
// override via service config.
|
|
434
|
+
model: 'gpt-4.1-nano',
|
|
416
435
|
tokenLimit: 4096,
|
|
417
436
|
temperature: 0.32,
|
|
418
|
-
|
|
437
|
+
// Bumped 12 → 24 (#845, PR 3). The OpenAI fast tier comfortably
|
|
438
|
+
// handles ~30 concurrent on the per-key default rate limit; 24
|
|
439
|
+
// leaves headroom for retries while still doubling throughput.
|
|
440
|
+
// The summarize chain has a 429-aware backoff (`summarize`
|
|
441
|
+
// helper) so a temporary rate-limit hit no longer kills the
|
|
442
|
+
// whole pipeline.
|
|
443
|
+
maxConcurrent: 24,
|
|
419
444
|
minTokensForSummary: 800,
|
|
420
445
|
maxFileTokens: 2000,
|
|
421
446
|
authentication: {
|
|
@@ -427,10 +452,20 @@ const DEFAULT_OPENAI_LLM_SERVICE = {
|
|
|
427
452
|
};
|
|
428
453
|
const DEFAULT_ANTHROPIC_LLM_SERVICE = {
|
|
429
454
|
provider: 'anthropic',
|
|
430
|
-
|
|
455
|
+
// Bumped from `claude-3-5-sonnet-20240620` to
|
|
456
|
+
// `claude-haiku-4-5-20251001` (#854). The Sonnet 3.5 default
|
|
457
|
+
// was nearly two model generations stale; Haiku 4.5 is the
|
|
458
|
+
// current fast tier and the right fit for diff summarization.
|
|
459
|
+
// Users who want Sonnet for quality-sensitive runs can still
|
|
460
|
+
// override via service config (recommended: `claude-sonnet-4-6`).
|
|
461
|
+
model: 'claude-haiku-4-5-20251001',
|
|
431
462
|
temperature: 0.32,
|
|
432
463
|
tokenLimit: 4096,
|
|
433
|
-
|
|
464
|
+
// Bumped 12 → 24 (#845, PR 3). Matches the OpenAI default;
|
|
465
|
+
// Anthropic's per-key concurrency on Haiku is generous enough
|
|
466
|
+
// that 24 stays under the rate ceiling for typical fast-model
|
|
467
|
+
// request shapes. Backoff in `summarize` handles spikes.
|
|
468
|
+
maxConcurrent: 24,
|
|
434
469
|
minTokensForSummary: 800,
|
|
435
470
|
maxFileTokens: 2000,
|
|
436
471
|
authentication: {
|
|
@@ -1414,6 +1449,10 @@ const schema$1 = {
|
|
|
1414
1449
|
"AnthropicModel": {
|
|
1415
1450
|
"type": "string",
|
|
1416
1451
|
"enum": [
|
|
1452
|
+
"claude-sonnet-4-6",
|
|
1453
|
+
"claude-haiku-4-5-20251001",
|
|
1454
|
+
"claude-haiku-4-5",
|
|
1455
|
+
"claude-opus-4-7",
|
|
1417
1456
|
"claude-sonnet-4-0",
|
|
1418
1457
|
"claude-3-7-sonnet-latest",
|
|
1419
1458
|
"claude-3-5-haiku-latest",
|
|
@@ -1947,6 +1986,44 @@ function parseServiceConfig(service) {
|
|
|
1947
1986
|
}
|
|
1948
1987
|
}
|
|
1949
1988
|
|
|
1989
|
+
/**
|
|
1990
|
+
* Ensure the canonical default ignore lists are always present in
|
|
1991
|
+
* the resolved config (#851). User-provided `ignoredFiles` /
|
|
1992
|
+
* `ignoredExtensions` arrays from XDG / git / project / env config
|
|
1993
|
+
* sources used to *replace* the defaults wholesale via the shallow
|
|
1994
|
+
* spread in each loader, which silently dropped lockfile + node_modules
|
|
1995
|
+
* entries the moment a user provided their own list. The reported
|
|
1996
|
+
* symptom: `pnpm-lock.yaml` reaching the diff-condensing pipeline
|
|
1997
|
+
* after a user added `.coco.config.json` for unrelated overrides.
|
|
1998
|
+
*
|
|
1999
|
+
* Now: user values are *unioned* with the defaults. Order is preserved
|
|
2000
|
+
* (defaults first, then user-only additions in their original order).
|
|
2001
|
+
* Duplicates are de-duped. The defaults can no longer be opted out of —
|
|
2002
|
+
* the cost of accidentally summarizing a lockfile (minutes of LLM time
|
|
2003
|
+
* per commit) outweighs the niche case of intentionally excluding a
|
|
2004
|
+
* default lockfile pattern.
|
|
2005
|
+
*/
|
|
2006
|
+
function unionPreservingOrder(base, extras) {
|
|
2007
|
+
if (!extras || extras.length === 0)
|
|
2008
|
+
return [...base];
|
|
2009
|
+
const seen = new Set(base);
|
|
2010
|
+
const merged = [...base];
|
|
2011
|
+
for (const value of extras) {
|
|
2012
|
+
if (!seen.has(value)) {
|
|
2013
|
+
seen.add(value);
|
|
2014
|
+
merged.push(value);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
return merged;
|
|
2018
|
+
}
|
|
2019
|
+
function mergeIgnoreLists(config) {
|
|
2020
|
+
return {
|
|
2021
|
+
...config,
|
|
2022
|
+
ignoredFiles: unionPreservingOrder(DEFAULT_IGNORED_FILES$1, config.ignoredFiles),
|
|
2023
|
+
ignoredExtensions: unionPreservingOrder(DEFAULT_IGNORED_EXTENSIONS$1, config.ignoredExtensions),
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2026
|
+
|
|
1950
2027
|
/**
|
|
1951
2028
|
* Tracked config sources populated during the last loadConfig call.
|
|
1952
2029
|
* Useful for diagnostics (e.g. `coco doctor`).
|
|
@@ -1997,6 +2074,13 @@ function loadConfig(argv = {}) {
|
|
|
1997
2074
|
config = envConfig;
|
|
1998
2075
|
if (envActive)
|
|
1999
2076
|
sources.push({ source: 'env' });
|
|
2077
|
+
// Re-apply the canonical default ignore lists after every loader has
|
|
2078
|
+
// had a chance to override (#851). Each loader replaces ignoredFiles
|
|
2079
|
+
// / ignoredExtensions wholesale via shallow spread, which used to
|
|
2080
|
+
// silently drop the lockfile + node_modules defaults the moment a
|
|
2081
|
+
// user provided their own list. The merge is a union — defaults first,
|
|
2082
|
+
// user-only entries appended.
|
|
2083
|
+
config = mergeIgnoreLists(config);
|
|
2000
2084
|
_lastConfigSources = sources;
|
|
2001
2085
|
return { ...config, ...argv };
|
|
2002
2086
|
}
|
|
@@ -2171,6 +2255,232 @@ function commandExecutor(handler) {
|
|
|
2171
2255
|
};
|
|
2172
2256
|
}
|
|
2173
2257
|
|
|
2258
|
+
const command$8 = 'cache <subcommand>';
|
|
2259
|
+
const builder$8 = (yargs) => {
|
|
2260
|
+
return yargs
|
|
2261
|
+
.positional('subcommand', {
|
|
2262
|
+
describe: 'Cache action to run (clear, info)',
|
|
2263
|
+
type: 'string',
|
|
2264
|
+
choices: ['clear', 'info'],
|
|
2265
|
+
})
|
|
2266
|
+
.usage(getCommandUsageHeader(command$8));
|
|
2267
|
+
};
|
|
2268
|
+
|
|
2269
|
+
/**
|
|
2270
|
+
* Per-repo disk cache of LLM-summarized diffs (#845, PR 5). On a
|
|
2271
|
+
* re-run of `coco commit` after a small change, most files have
|
|
2272
|
+
* unchanged content and unchanged diffs — caching their summaries
|
|
2273
|
+
* by content hash means the second run skips the LLM entirely for
|
|
2274
|
+
* those files and only pays for what's actually different.
|
|
2275
|
+
*
|
|
2276
|
+
* Strict best-effort: read failures fall back to "no cache" (the
|
|
2277
|
+
* pipeline runs the LLM as before), and write failures are
|
|
2278
|
+
* swallowed silently. The cache is never load-bearing.
|
|
2279
|
+
*
|
|
2280
|
+
* Repos are keyed by a short hash of their absolute path. No PII
|
|
2281
|
+
* in the cache filename, and re-creating a repo at the same path
|
|
2282
|
+
* keeps the same cache.
|
|
2283
|
+
*
|
|
2284
|
+
* Cache key: `sha256(diff + ':' + model + ':' + promptHash)`.
|
|
2285
|
+
* - diff: the literal diff text being summarized
|
|
2286
|
+
* - model: switching models invalidates (different summaries)
|
|
2287
|
+
* - promptHash: editing the SUMMARIZE_PROMPT template invalidates
|
|
2288
|
+
*
|
|
2289
|
+
* Cap: 500 entries per repo. LRU eviction on overflow keeps the
|
|
2290
|
+
* cache file under ~500 KB on a typical repo (each entry is a
|
|
2291
|
+
* sha256 hash + 200-500-byte summary).
|
|
2292
|
+
*/
|
|
2293
|
+
const CACHE_SCHEMA_VERSION$1 = 1;
|
|
2294
|
+
const CACHE_DIR_NAME$1 = 'diff-summaries';
|
|
2295
|
+
const CACHE_ENTRY_HARD_CAP = 500;
|
|
2296
|
+
function resolveCacheDir$4() {
|
|
2297
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
2298
|
+
if (xdg && xdg.trim().length > 0) {
|
|
2299
|
+
return path$1.join(xdg, 'coco', CACHE_DIR_NAME$1);
|
|
2300
|
+
}
|
|
2301
|
+
return path$1.join(os$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$1);
|
|
2302
|
+
}
|
|
2303
|
+
function repoKey$3(repoPath) {
|
|
2304
|
+
// sha256 here is a non-security cache-key derivation — deterministic
|
|
2305
|
+
// short identifier for the cache filename so two repos at different
|
|
2306
|
+
// paths never collide. We truncate to 16 chars; collision-resistance
|
|
2307
|
+
// against an adversary is not required.
|
|
2308
|
+
return crypto.createHash('sha256').update(repoPath).digest('hex').slice(0, 16);
|
|
2309
|
+
}
|
|
2310
|
+
function getDiffSummaryCachePath(repoPath) {
|
|
2311
|
+
return path$1.join(resolveCacheDir$4(), `summaries.${repoKey$3(repoPath)}.json`);
|
|
2312
|
+
}
|
|
2313
|
+
/**
|
|
2314
|
+
* Build the cache key for a (diff, model, prompt) tuple. sha256
|
|
2315
|
+
* because we want a strong content-hash; the per-entry storage cost
|
|
2316
|
+
* is dominated by the summary text anyway.
|
|
2317
|
+
*/
|
|
2318
|
+
function diffSummaryKey(diff, model, promptHash) {
|
|
2319
|
+
return crypto
|
|
2320
|
+
.createHash('sha256')
|
|
2321
|
+
.update(`${diff}\x1f${model}\x1f${promptHash}`)
|
|
2322
|
+
.digest('hex');
|
|
2323
|
+
}
|
|
2324
|
+
function readEnvelope(filePath) {
|
|
2325
|
+
try {
|
|
2326
|
+
const raw = fs$1.readFileSync(filePath, 'utf8');
|
|
2327
|
+
const parsed = JSON.parse(raw);
|
|
2328
|
+
if (parsed.version !== CACHE_SCHEMA_VERSION$1)
|
|
2329
|
+
return undefined;
|
|
2330
|
+
if (!parsed.entries || typeof parsed.entries !== 'object')
|
|
2331
|
+
return undefined;
|
|
2332
|
+
return parsed;
|
|
2333
|
+
}
|
|
2334
|
+
catch {
|
|
2335
|
+
return undefined;
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
function readDiffSummary(repoPath, key) {
|
|
2339
|
+
const envelope = readEnvelope(getDiffSummaryCachePath(repoPath));
|
|
2340
|
+
if (!envelope)
|
|
2341
|
+
return undefined;
|
|
2342
|
+
const entry = envelope.entries[key];
|
|
2343
|
+
if (!entry)
|
|
2344
|
+
return undefined;
|
|
2345
|
+
return entry;
|
|
2346
|
+
}
|
|
2347
|
+
function writeDiffSummary(repoPath, key, entry) {
|
|
2348
|
+
const filePath = getDiffSummaryCachePath(repoPath);
|
|
2349
|
+
const existing = readEnvelope(filePath) || {
|
|
2350
|
+
version: CACHE_SCHEMA_VERSION$1,
|
|
2351
|
+
savedAt: new Date().toISOString(),
|
|
2352
|
+
entries: {},
|
|
2353
|
+
};
|
|
2354
|
+
existing.entries[key] = { ...entry, lastAccessedAt: new Date().toISOString() };
|
|
2355
|
+
existing.savedAt = new Date().toISOString();
|
|
2356
|
+
const evictedEntries = enforceHardCap(existing.entries);
|
|
2357
|
+
if (evictedEntries.length > 0) {
|
|
2358
|
+
for (const evicted of evictedEntries) {
|
|
2359
|
+
delete existing.entries[evicted];
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
try {
|
|
2363
|
+
fs$1.mkdirSync(path$1.dirname(filePath), { recursive: true });
|
|
2364
|
+
fs$1.writeFileSync(filePath, JSON.stringify(existing));
|
|
2365
|
+
}
|
|
2366
|
+
catch {
|
|
2367
|
+
// Best-effort persistence; swallow.
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
/**
|
|
2371
|
+
* Touch an existing entry's lastAccessedAt so LRU eviction prefers
|
|
2372
|
+
* dropping older / unused entries. Caller is expected to know the
|
|
2373
|
+
* entry exists (read returned a hit).
|
|
2374
|
+
*/
|
|
2375
|
+
function touchDiffSummary(repoPath, key) {
|
|
2376
|
+
const filePath = getDiffSummaryCachePath(repoPath);
|
|
2377
|
+
const envelope = readEnvelope(filePath);
|
|
2378
|
+
if (!envelope || !envelope.entries[key])
|
|
2379
|
+
return;
|
|
2380
|
+
envelope.entries[key] = {
|
|
2381
|
+
...envelope.entries[key],
|
|
2382
|
+
lastAccessedAt: new Date().toISOString(),
|
|
2383
|
+
};
|
|
2384
|
+
envelope.savedAt = new Date().toISOString();
|
|
2385
|
+
try {
|
|
2386
|
+
fs$1.writeFileSync(filePath, JSON.stringify(envelope));
|
|
2387
|
+
}
|
|
2388
|
+
catch {
|
|
2389
|
+
// Swallow.
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
function enforceHardCap(entries) {
|
|
2393
|
+
const keys = Object.keys(entries);
|
|
2394
|
+
if (keys.length <= CACHE_ENTRY_HARD_CAP)
|
|
2395
|
+
return [];
|
|
2396
|
+
// Sort by lastAccessedAt ascending (oldest first), drop the
|
|
2397
|
+
// oldest (keys.length - CACHE_ENTRY_HARD_CAP) entries.
|
|
2398
|
+
const sorted = keys
|
|
2399
|
+
.map((key) => ({ key, accessed: Date.parse(entries[key].lastAccessedAt) || 0 }))
|
|
2400
|
+
.sort((a, b) => a.accessed - b.accessed);
|
|
2401
|
+
const toEvict = sorted.slice(0, keys.length - CACHE_ENTRY_HARD_CAP).map((entry) => entry.key);
|
|
2402
|
+
return toEvict;
|
|
2403
|
+
}
|
|
2404
|
+
/** Remove the entire cache file for the repo. Used by `coco cache:clear`. */
|
|
2405
|
+
function clearDiffSummaryCache(repoPath) {
|
|
2406
|
+
const filePath = getDiffSummaryCachePath(repoPath);
|
|
2407
|
+
if (!fs$1.existsSync(filePath)) {
|
|
2408
|
+
return { ok: true, removed: false };
|
|
2409
|
+
}
|
|
2410
|
+
try {
|
|
2411
|
+
fs$1.unlinkSync(filePath);
|
|
2412
|
+
return { ok: true, removed: true };
|
|
2413
|
+
}
|
|
2414
|
+
catch {
|
|
2415
|
+
return { ok: false, removed: false };
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
function readEnvelopeOrUndefined(filePath) {
|
|
2420
|
+
try {
|
|
2421
|
+
if (!fs$1.existsSync(filePath))
|
|
2422
|
+
return undefined;
|
|
2423
|
+
const raw = fs$1.readFileSync(filePath, 'utf8');
|
|
2424
|
+
return JSON.parse(raw);
|
|
2425
|
+
}
|
|
2426
|
+
catch {
|
|
2427
|
+
return undefined;
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
function formatBytes(bytes) {
|
|
2431
|
+
if (bytes < 1024)
|
|
2432
|
+
return `${bytes} B`;
|
|
2433
|
+
if (bytes < 1024 * 1024)
|
|
2434
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
2435
|
+
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
2436
|
+
}
|
|
2437
|
+
const handler$8 = async (argv, logger) => {
|
|
2438
|
+
const subcommand = argv.subcommand;
|
|
2439
|
+
const repoPath = process.cwd();
|
|
2440
|
+
const cachePath = getDiffSummaryCachePath(repoPath);
|
|
2441
|
+
if (subcommand === 'clear') {
|
|
2442
|
+
const result = clearDiffSummaryCache(repoPath);
|
|
2443
|
+
if (!result.ok) {
|
|
2444
|
+
logger.log(chalk.red(`Failed to clear diff-summary cache at ${cachePath}`));
|
|
2445
|
+
process.exitCode = 1;
|
|
2446
|
+
return;
|
|
2447
|
+
}
|
|
2448
|
+
if (result.removed) {
|
|
2449
|
+
logger.log(chalk.green(`Cleared diff-summary cache at ${cachePath}`));
|
|
2450
|
+
}
|
|
2451
|
+
else {
|
|
2452
|
+
logger.log(chalk.dim(`No diff-summary cache to clear (${cachePath})`));
|
|
2453
|
+
}
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
if (subcommand === 'info') {
|
|
2457
|
+
const envelope = readEnvelopeOrUndefined(cachePath);
|
|
2458
|
+
if (!envelope) {
|
|
2459
|
+
logger.log(chalk.dim(`No diff-summary cache for this repo (${cachePath})`));
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
const stat = fs$1.statSync(cachePath);
|
|
2463
|
+
const entryCount = Object.keys(envelope.entries).length;
|
|
2464
|
+
const totalSummaryTokens = Object.values(envelope.entries).reduce((sum, entry) => sum + entry.tokens, 0);
|
|
2465
|
+
logger.log(chalk.bold('Diff-summary cache') + ` ${chalk.dim(cachePath)}`);
|
|
2466
|
+
logger.log(` ${chalk.green('entries')} ${entryCount}`);
|
|
2467
|
+
logger.log(` ${chalk.green('on-disk size')} ${formatBytes(stat.size)}`);
|
|
2468
|
+
logger.log(` ${chalk.green('summary tokens')} ${totalSummaryTokens}`);
|
|
2469
|
+
logger.log(` ${chalk.green('last saved')} ${envelope.savedAt}`);
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`));
|
|
2473
|
+
logger.log(chalk.dim('Use one of: clear, info'));
|
|
2474
|
+
process.exitCode = 1;
|
|
2475
|
+
};
|
|
2476
|
+
|
|
2477
|
+
var cache = {
|
|
2478
|
+
command: command$8,
|
|
2479
|
+
desc: 'Manage the diff-summary cache (clear, info)',
|
|
2480
|
+
builder: builder$8,
|
|
2481
|
+
handler: commandExecutor(handler$8),
|
|
2482
|
+
};
|
|
2483
|
+
|
|
2174
2484
|
var util;
|
|
2175
2485
|
(function (util) {
|
|
2176
2486
|
util.assertEqual = (_) => { };
|
|
@@ -7505,6 +7815,56 @@ function getPathFromFilePath(filePath) {
|
|
|
7505
7815
|
return filePath.split('/').slice(0, -1).join('/');
|
|
7506
7816
|
}
|
|
7507
7817
|
|
|
7818
|
+
/**
|
|
7819
|
+
* Adaptive backoff (#845, PR 3). Wraps the chain invocation so a
|
|
7820
|
+
* transient 429 (rate limit) or 5xx no longer kills the whole
|
|
7821
|
+
* pipeline — instead we wait briefly and retry up to N times
|
|
7822
|
+
* before surfacing the failure.
|
|
7823
|
+
*
|
|
7824
|
+
* Cap is intentionally short. Diff condensing fans out to many
|
|
7825
|
+
* concurrent calls; if rate limits hit hard, queueing requests
|
|
7826
|
+
* indefinitely just makes the user wait longer for a result the
|
|
7827
|
+
* pipeline ultimately handles via fewer concurrent passes anyway.
|
|
7828
|
+
* 3 retries with 1s/2s/4s waits trade ~7s of worst-case extra
|
|
7829
|
+
* latency for resilience to brief rate-limit blips.
|
|
7830
|
+
*/
|
|
7831
|
+
const BACKOFF_RETRIES = 3;
|
|
7832
|
+
const BACKOFF_BASE_MS = 1000;
|
|
7833
|
+
const BACKOFF_CAP_MS = 5000;
|
|
7834
|
+
function isRetryableError(error) {
|
|
7835
|
+
if (!error || typeof error !== 'object')
|
|
7836
|
+
return false;
|
|
7837
|
+
const err = error;
|
|
7838
|
+
if (err.status === 429 || err.status === 503 || err.status === 502 || err.status === 504) {
|
|
7839
|
+
return true;
|
|
7840
|
+
}
|
|
7841
|
+
if (err.code === 429 || err.code === 'rate_limit_exceeded' || err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT') {
|
|
7842
|
+
return true;
|
|
7843
|
+
}
|
|
7844
|
+
if (typeof err.message === 'string' && /(rate.?limit|429|too many requests|timeout|temporarily unavailable)/i.test(err.message)) {
|
|
7845
|
+
return true;
|
|
7846
|
+
}
|
|
7847
|
+
return false;
|
|
7848
|
+
}
|
|
7849
|
+
async function invokeWithBackoff(chain, input, logger) {
|
|
7850
|
+
let lastError;
|
|
7851
|
+
for (let attempt = 0; attempt <= BACKOFF_RETRIES; attempt++) {
|
|
7852
|
+
try {
|
|
7853
|
+
return await chain.invoke(input);
|
|
7854
|
+
}
|
|
7855
|
+
catch (error) {
|
|
7856
|
+
lastError = error;
|
|
7857
|
+
if (!isRetryableError(error) || attempt === BACKOFF_RETRIES) {
|
|
7858
|
+
throw error;
|
|
7859
|
+
}
|
|
7860
|
+
const wait = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * Math.pow(2, attempt));
|
|
7861
|
+
logger?.verbose(`[summarize] retryable error (attempt ${attempt + 1}/${BACKOFF_RETRIES}); backing off ${wait}ms`, { color: 'yellow' });
|
|
7862
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
7863
|
+
}
|
|
7864
|
+
}
|
|
7865
|
+
// Unreachable — the loop either returns or rethrows above.
|
|
7866
|
+
throw lastError;
|
|
7867
|
+
}
|
|
7508
7868
|
async function summarize(documents, { chain, textSplitter, options, logger, tokenizer, metadata }) {
|
|
7509
7869
|
const { returnIntermediateSteps = false } = options || {};
|
|
7510
7870
|
const docs = await textSplitter.splitDocuments(documents.map((doc) => new Document(doc)));
|
|
@@ -7512,10 +7872,10 @@ async function summarize(documents, { chain, textSplitter, options, logger, toke
|
|
|
7512
7872
|
? docs.reduce((sum, doc) => sum + tokenizer(doc.pageContent), 0)
|
|
7513
7873
|
: undefined;
|
|
7514
7874
|
const startedAt = Date.now();
|
|
7515
|
-
const res = await chain
|
|
7875
|
+
const res = await invokeWithBackoff(chain, {
|
|
7516
7876
|
input_documents: docs,
|
|
7517
7877
|
returnIntermediateSteps,
|
|
7518
|
-
});
|
|
7878
|
+
}, logger);
|
|
7519
7879
|
const elapsedMs = Date.now() - startedAt;
|
|
7520
7880
|
logLlmCall(logger, {
|
|
7521
7881
|
task: 'summarize',
|
|
@@ -7530,10 +7890,175 @@ async function summarize(documents, { chain, textSplitter, options, logger, toke
|
|
|
7530
7890
|
return res.text && res.text.trim();
|
|
7531
7891
|
}
|
|
7532
7892
|
|
|
7893
|
+
/**
|
|
7894
|
+
* Inspect a unified-diff string and report its shape, or undefined
|
|
7895
|
+
* if the diff isn't trivial (mixed +/- lines, weird headers, etc.).
|
|
7896
|
+
*
|
|
7897
|
+
* Detection rules (cheap on purpose — we're called per-file and the
|
|
7898
|
+
* goal is to skip work, not be exhaustive):
|
|
7899
|
+
*
|
|
7900
|
+
* - `Binary files ... differ` header → 'binary'
|
|
7901
|
+
* - `rename from`/`rename to` headers and no `+`/`-` content
|
|
7902
|
+
* lines → 'rename'
|
|
7903
|
+
* - All content lines are `+` (and at least one is) → 'addition'
|
|
7904
|
+
* - All content lines are `-` (and at least one is) → 'deletion'
|
|
7905
|
+
* - Otherwise → undefined (let the LLM handle it)
|
|
7906
|
+
*/
|
|
7907
|
+
function detectTrivialDiffShape(diff) {
|
|
7908
|
+
if (!diff)
|
|
7909
|
+
return undefined;
|
|
7910
|
+
// Binary marker is unambiguous and short-circuits early.
|
|
7911
|
+
if (/^Binary files .+ and .+ differ$/m.test(diff)) {
|
|
7912
|
+
return 'binary';
|
|
7913
|
+
}
|
|
7914
|
+
// Pure rename: git emits `rename from` / `rename to` and no body.
|
|
7915
|
+
// We require BOTH markers AND no `+`/`-` content lines. Some
|
|
7916
|
+
// renames-with-edit show rename headers AND a hunk; those should
|
|
7917
|
+
// fall through to the LLM path.
|
|
7918
|
+
const hasRenameFrom = /^rename from /m.test(diff);
|
|
7919
|
+
const hasRenameTo = /^rename to /m.test(diff);
|
|
7920
|
+
if (hasRenameFrom && hasRenameTo) {
|
|
7921
|
+
const hasContentChange = diff
|
|
7922
|
+
.split('\n')
|
|
7923
|
+
.some((line) => isContentChangeLine(line));
|
|
7924
|
+
if (!hasContentChange) {
|
|
7925
|
+
return 'rename';
|
|
7926
|
+
}
|
|
7927
|
+
}
|
|
7928
|
+
// Walk the body once classifying content lines. We skip header
|
|
7929
|
+
// lines (diff --git, index, ---, +++, @@, etc.) and only inspect
|
|
7930
|
+
// the lines that represent actual change content.
|
|
7931
|
+
let plus = 0;
|
|
7932
|
+
let minus = 0;
|
|
7933
|
+
for (const line of diff.split('\n')) {
|
|
7934
|
+
if (isHeaderLine(line))
|
|
7935
|
+
continue;
|
|
7936
|
+
if (line.startsWith('+'))
|
|
7937
|
+
plus++;
|
|
7938
|
+
else if (line.startsWith('-'))
|
|
7939
|
+
minus++;
|
|
7940
|
+
// Context lines (' ' prefix) are ignored for shape classification:
|
|
7941
|
+
// a pure addition can still have surrounding context if a hunk
|
|
7942
|
+
// anchors at line 0, though `git diff` for a brand-new file
|
|
7943
|
+
// typically has none.
|
|
7944
|
+
}
|
|
7945
|
+
if (plus > 0 && minus === 0)
|
|
7946
|
+
return 'addition';
|
|
7947
|
+
if (minus > 0 && plus === 0)
|
|
7948
|
+
return 'deletion';
|
|
7949
|
+
return undefined;
|
|
7950
|
+
}
|
|
7951
|
+
/**
|
|
7952
|
+
* Build a deterministic summary string for a trivial diff. Returns
|
|
7953
|
+
* undefined when the shape can't be templated (caller should fall
|
|
7954
|
+
* back to the LLM path).
|
|
7955
|
+
*/
|
|
7956
|
+
function summarizeTrivialDiff(fileDiff) {
|
|
7957
|
+
const shape = detectTrivialDiffShape(fileDiff.diff);
|
|
7958
|
+
if (!shape)
|
|
7959
|
+
return undefined;
|
|
7960
|
+
const lineCount = countContentLines(fileDiff.diff, shape);
|
|
7961
|
+
switch (shape) {
|
|
7962
|
+
case 'addition':
|
|
7963
|
+
return `Added \`${fileDiff.file}\` (${lineCount} line${lineCount === 1 ? '' : 's'}).`;
|
|
7964
|
+
case 'deletion':
|
|
7965
|
+
return `Removed \`${fileDiff.file}\` (${lineCount} line${lineCount === 1 ? '' : 's'}).`;
|
|
7966
|
+
case 'rename': {
|
|
7967
|
+
const oldPath = extractRenameOldPath(fileDiff.diff);
|
|
7968
|
+
return oldPath
|
|
7969
|
+
? `Renamed \`${oldPath}\` → \`${fileDiff.file}\`.`
|
|
7970
|
+
: `Renamed file to \`${fileDiff.file}\`.`;
|
|
7971
|
+
}
|
|
7972
|
+
case 'binary':
|
|
7973
|
+
return `Updated binary file \`${fileDiff.file}\`.`;
|
|
7974
|
+
}
|
|
7975
|
+
}
|
|
7976
|
+
function isHeaderLine(line) {
|
|
7977
|
+
return (line.startsWith('diff --git') ||
|
|
7978
|
+
line.startsWith('index ') ||
|
|
7979
|
+
line.startsWith('--- ') ||
|
|
7980
|
+
line.startsWith('+++ ') ||
|
|
7981
|
+
line.startsWith('@@') ||
|
|
7982
|
+
line.startsWith('new file mode') ||
|
|
7983
|
+
line.startsWith('deleted file mode') ||
|
|
7984
|
+
line.startsWith('similarity index') ||
|
|
7985
|
+
line.startsWith('rename from ') ||
|
|
7986
|
+
line.startsWith('rename to ') ||
|
|
7987
|
+
line.startsWith('Binary files '));
|
|
7988
|
+
}
|
|
7989
|
+
function isContentChangeLine(line) {
|
|
7990
|
+
if (isHeaderLine(line))
|
|
7991
|
+
return false;
|
|
7992
|
+
return line.startsWith('+') || line.startsWith('-');
|
|
7993
|
+
}
|
|
7994
|
+
function countContentLines(diff, shape) {
|
|
7995
|
+
if (shape === 'binary' || shape === 'rename')
|
|
7996
|
+
return 0;
|
|
7997
|
+
const prefix = shape === 'addition' ? '+' : '-';
|
|
7998
|
+
let count = 0;
|
|
7999
|
+
for (const line of diff.split('\n')) {
|
|
8000
|
+
if (isHeaderLine(line))
|
|
8001
|
+
continue;
|
|
8002
|
+
if (line.startsWith(prefix))
|
|
8003
|
+
count++;
|
|
8004
|
+
}
|
|
8005
|
+
return count;
|
|
8006
|
+
}
|
|
8007
|
+
function extractRenameOldPath(diff) {
|
|
8008
|
+
const match = diff.match(/^rename from (.+)$/m);
|
|
8009
|
+
return match ? match[1].trim() : undefined;
|
|
8010
|
+
}
|
|
8011
|
+
|
|
8012
|
+
/**
|
|
8013
|
+
* Cache opt-out: COCO_NO_CACHE=1 disables both reads and writes
|
|
8014
|
+
* for the diff-summary cache (#845, PR 5). Default is enabled.
|
|
8015
|
+
*/
|
|
8016
|
+
function isCacheEnabled$1() {
|
|
8017
|
+
return !process.env.COCO_NO_CACHE || process.env.COCO_NO_CACHE === '0';
|
|
8018
|
+
}
|
|
7533
8019
|
/**
|
|
7534
8020
|
* Summarize a single file diff that exceeds the token threshold.
|
|
8021
|
+
*
|
|
8022
|
+
* Trivial-shape short-circuit (#845, PR 2): pure additions / deletions
|
|
8023
|
+
* / renames / binary changes have no information content beyond the
|
|
8024
|
+
* diff's shape, so we templated-summarize them instead of paying for
|
|
8025
|
+
* an LLM call. On initial-commit fixtures (lots of pure adds) this
|
|
8026
|
+
* collapses the per-file summary phase entirely; the resulting tiny
|
|
8027
|
+
* synthetic summaries usually drop the directory token totals under
|
|
8028
|
+
* budget so wave consolidation skips too.
|
|
7535
8029
|
*/
|
|
7536
8030
|
async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, logger, metadata, }) {
|
|
8031
|
+
const trivialSummary = summarizeTrivialDiff(fileDiff);
|
|
8032
|
+
if (trivialSummary !== undefined) {
|
|
8033
|
+
logger.verbose(` - ${fileDiff.file}: trivial-shape skip (no LLM call)`, { color: 'gray' });
|
|
8034
|
+
return {
|
|
8035
|
+
...fileDiff,
|
|
8036
|
+
diff: trivialSummary,
|
|
8037
|
+
tokenCount: tokenizer(trivialSummary),
|
|
8038
|
+
};
|
|
8039
|
+
}
|
|
8040
|
+
// Cache lookup (#845, PR 5). Keyed on the file's literal diff
|
|
8041
|
+
// content + the active model + the summarization prompt hash.
|
|
8042
|
+
// A hit returns the prior summary instantly; on iterative
|
|
8043
|
+
// `coco commit` re-runs after small edits, the unchanged files
|
|
8044
|
+
// never go to the LLM.
|
|
8045
|
+
const cacheModel = typeof metadata?.model === 'string' ? metadata.model : undefined;
|
|
8046
|
+
const cacheRepo = process.cwd();
|
|
8047
|
+
const cacheKey = isCacheEnabled$1() && cacheModel
|
|
8048
|
+
? diffSummaryKey(fileDiff.diff, cacheModel, SUMMARIZE_PROMPT_HASH)
|
|
8049
|
+
: undefined;
|
|
8050
|
+
if (cacheKey) {
|
|
8051
|
+
const cached = readDiffSummary(cacheRepo, cacheKey);
|
|
8052
|
+
if (cached) {
|
|
8053
|
+
logger.verbose(` - ${fileDiff.file}: cache hit (skipped LLM, ${cached.tokens} tokens)`, { color: 'cyan' });
|
|
8054
|
+
touchDiffSummary(cacheRepo, cacheKey);
|
|
8055
|
+
return {
|
|
8056
|
+
...fileDiff,
|
|
8057
|
+
diff: cached.summary,
|
|
8058
|
+
tokenCount: cached.tokens,
|
|
8059
|
+
};
|
|
8060
|
+
}
|
|
8061
|
+
}
|
|
7537
8062
|
try {
|
|
7538
8063
|
const fileSummary = await summarize([
|
|
7539
8064
|
{
|
|
@@ -7557,6 +8082,13 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
|
|
|
7557
8082
|
},
|
|
7558
8083
|
});
|
|
7559
8084
|
const newTokenCount = tokenizer(fileSummary);
|
|
8085
|
+
if (cacheKey && cacheModel) {
|
|
8086
|
+
writeDiffSummary(cacheRepo, cacheKey, {
|
|
8087
|
+
summary: fileSummary,
|
|
8088
|
+
model: cacheModel,
|
|
8089
|
+
tokens: newTokenCount,
|
|
8090
|
+
});
|
|
8091
|
+
}
|
|
7560
8092
|
return {
|
|
7561
8093
|
...fileDiff,
|
|
7562
8094
|
diff: fileSummary,
|
|
@@ -7570,16 +8102,41 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
|
|
|
7570
8102
|
}
|
|
7571
8103
|
}
|
|
7572
8104
|
/**
|
|
7573
|
-
*
|
|
8105
|
+
* Continuous-queue scheduler (#845, PR 4). Mirrors the directory-
|
|
8106
|
+
* level scheduler in `summarizeDiffs.ts` and replaces the previous
|
|
8107
|
+
* fixed-wave Promise.all loop, which made the slowest call in
|
|
8108
|
+
* each wave block the next wave from starting. With realistic LLM
|
|
8109
|
+
* tail variance, that wave-locking adds dead time at every wave
|
|
8110
|
+
* boundary; continuous queue fills slots as in-flight calls
|
|
8111
|
+
* resolve, so the wall-clock tracks the slowest *call*, not the
|
|
8112
|
+
* sum of slowest-per-wave.
|
|
7574
8113
|
*/
|
|
7575
8114
|
async function processInWaves$1(items, processor, maxConcurrent) {
|
|
7576
|
-
const
|
|
7577
|
-
|
|
7578
|
-
|
|
7579
|
-
|
|
7580
|
-
|
|
7581
|
-
|
|
7582
|
-
|
|
8115
|
+
const limit = createLimit$2(maxConcurrent);
|
|
8116
|
+
return Promise.all(items.map((item) => limit(() => processor(item))));
|
|
8117
|
+
}
|
|
8118
|
+
function createLimit$2(maxConcurrent) {
|
|
8119
|
+
const limit = Math.max(1, maxConcurrent);
|
|
8120
|
+
let active = 0;
|
|
8121
|
+
const queue = [];
|
|
8122
|
+
const runNext = () => {
|
|
8123
|
+
active--;
|
|
8124
|
+
const next = queue.shift();
|
|
8125
|
+
if (next)
|
|
8126
|
+
next();
|
|
8127
|
+
};
|
|
8128
|
+
return async (operation) => {
|
|
8129
|
+
if (active >= limit) {
|
|
8130
|
+
await new Promise((resolve) => queue.push(resolve));
|
|
8131
|
+
}
|
|
8132
|
+
active++;
|
|
8133
|
+
try {
|
|
8134
|
+
return await operation();
|
|
8135
|
+
}
|
|
8136
|
+
finally {
|
|
8137
|
+
runNext();
|
|
8138
|
+
}
|
|
8139
|
+
};
|
|
7583
8140
|
}
|
|
7584
8141
|
/**
|
|
7585
8142
|
* Pre-summarize individual files that exceed the maxFileTokens threshold.
|
|
@@ -7644,6 +8201,13 @@ async function preprocessLargeFiles(rootNode, options) {
|
|
|
7644
8201
|
return rebuildNode(rootNode);
|
|
7645
8202
|
}
|
|
7646
8203
|
|
|
8204
|
+
/**
|
|
8205
|
+
* Cache opt-out: COCO_NO_CACHE=1 disables both reads and writes
|
|
8206
|
+
* for the diff-summary cache (#845, PR 5). Default is enabled.
|
|
8207
|
+
*/
|
|
8208
|
+
function isCacheEnabled() {
|
|
8209
|
+
return !process.env.COCO_NO_CACHE || process.env.COCO_NO_CACHE === '0';
|
|
8210
|
+
}
|
|
7647
8211
|
/**
|
|
7648
8212
|
* Create groups from a given node info.
|
|
7649
8213
|
* @param {DiffNode} node - The node info to start grouping.
|
|
@@ -7669,6 +8233,32 @@ function createDirectoryDiffs(node) {
|
|
|
7669
8233
|
* Summarize a directory diff asynchronously.
|
|
7670
8234
|
*/
|
|
7671
8235
|
async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenizer, logger, metadata }) {
|
|
8236
|
+
// Cache lookup (#845, PR 5). Joined per-file diffs become the
|
|
8237
|
+
// payload signature; if every file in the directory is unchanged
|
|
8238
|
+
// since the last run (and the model + prompt match), the prior
|
|
8239
|
+
// directory-level summary is reused instead of paying for another
|
|
8240
|
+
// map_reduce pass.
|
|
8241
|
+
const cacheModel = typeof metadata?.model === 'string' ? metadata.model : undefined;
|
|
8242
|
+
const cacheRepo = process.cwd();
|
|
8243
|
+
const cachePayload = directory.diffs
|
|
8244
|
+
.map((diff) => `${diff.file}\x1e${diff.diff}`)
|
|
8245
|
+
.join('\x1d');
|
|
8246
|
+
const cacheKey = isCacheEnabled() && cacheModel
|
|
8247
|
+
? diffSummaryKey(cachePayload, cacheModel, SUMMARIZE_PROMPT_HASH)
|
|
8248
|
+
: undefined;
|
|
8249
|
+
if (cacheKey) {
|
|
8250
|
+
const cached = readDiffSummary(cacheRepo, cacheKey);
|
|
8251
|
+
if (cached) {
|
|
8252
|
+
logger?.verbose?.(` • Cache hit for "/${directory.path}" (skipped LLM, ${cached.tokens} tokens)`, { color: 'cyan' });
|
|
8253
|
+
touchDiffSummary(cacheRepo, cacheKey);
|
|
8254
|
+
return {
|
|
8255
|
+
diffs: directory.diffs,
|
|
8256
|
+
path: directory.path,
|
|
8257
|
+
summary: cached.summary,
|
|
8258
|
+
tokenCount: cached.tokens,
|
|
8259
|
+
};
|
|
8260
|
+
}
|
|
8261
|
+
}
|
|
7672
8262
|
try {
|
|
7673
8263
|
const directorySummary = await summarize(directory.diffs.map((diff) => ({
|
|
7674
8264
|
pageContent: diff.diff,
|
|
@@ -7690,6 +8280,13 @@ async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenize
|
|
|
7690
8280
|
},
|
|
7691
8281
|
});
|
|
7692
8282
|
const newTokenTotal = tokenizer(directorySummary);
|
|
8283
|
+
if (cacheKey && cacheModel) {
|
|
8284
|
+
writeDiffSummary(cacheRepo, cacheKey, {
|
|
8285
|
+
summary: directorySummary,
|
|
8286
|
+
model: cacheModel,
|
|
8287
|
+
tokens: newTokenTotal,
|
|
8288
|
+
});
|
|
8289
|
+
}
|
|
7693
8290
|
return {
|
|
7694
8291
|
diffs: directory.diffs,
|
|
7695
8292
|
path: directory.path,
|
|
@@ -7724,66 +8321,99 @@ const defaultOutputCallback = (group) => {
|
|
|
7724
8321
|
return output;
|
|
7725
8322
|
};
|
|
7726
8323
|
/**
|
|
7727
|
-
*
|
|
7728
|
-
*
|
|
8324
|
+
* Continuous-queue scheduler for the directory summarization pass
|
|
8325
|
+
* (#845, PR 4). The previous wave-by-wave Promise.all forced the
|
|
8326
|
+
* scheduler to wait for the slowest call in a wave before starting
|
|
8327
|
+
* the next wave; on a fixture like `refactor` (20 directories, mixed
|
|
8328
|
+
* sizes) one big directory could pin the wave at ~its own latency
|
|
8329
|
+
* even though the other 19 calls finished long before.
|
|
8330
|
+
*
|
|
8331
|
+
* The continuous queue dispatches all eligible directories through
|
|
8332
|
+
* a `createLimit(maxConcurrent)` semaphore — same primitive
|
|
8333
|
+
* `collectDiffs` already uses. As soon as any in-flight summary
|
|
8334
|
+
* resolves, the next eligible directory takes its slot. Each
|
|
8335
|
+
* scheduled call also re-checks the budget at the moment it would
|
|
8336
|
+
* fire; if the budget is already met (because earlier completions
|
|
8337
|
+
* dropped the total under maxTokens), it returns the original
|
|
8338
|
+
* directory without an LLM call. So the work scales with what's
|
|
8339
|
+
* actually needed, not with the worst-case wave count.
|
|
8340
|
+
*
|
|
8341
|
+
* Order discipline is preserved: directories are sorted by token
|
|
8342
|
+
* count descending and dispatched in that order. The biggest
|
|
8343
|
+
* candidates land in the first batch of in-flight calls; as smaller
|
|
8344
|
+
* candidates reach the queue front, the budget is more likely to
|
|
8345
|
+
* already be met and they short-circuit.
|
|
7729
8346
|
*/
|
|
7730
8347
|
async function summarizeInWaves(directories, options) {
|
|
7731
8348
|
const { totalTokenCount: initialTotal, maxTokens, minTokensForSummary, maxConcurrent, logger, chain, textSplitter, tokenizer, metadata, } = options;
|
|
7732
8349
|
let totalTokenCount = initialTotal;
|
|
7733
8350
|
const results = [...directories];
|
|
7734
|
-
//
|
|
7735
|
-
const
|
|
8351
|
+
// Pick eligible directories upfront, sorted big-first.
|
|
8352
|
+
const eligibleIndices = directories
|
|
7736
8353
|
.map((d, i) => ({ index: i, tokens: d.tokenCount }))
|
|
7737
|
-
.
|
|
7738
|
-
|
|
7739
|
-
|
|
7740
|
-
|
|
7741
|
-
|
|
7742
|
-
|
|
7743
|
-
|
|
7744
|
-
|
|
7745
|
-
|
|
7746
|
-
|
|
7747
|
-
|
|
7748
|
-
|
|
7749
|
-
|
|
7750
|
-
|
|
7751
|
-
cursor = i + 1;
|
|
7752
|
-
continue;
|
|
7753
|
-
}
|
|
7754
|
-
wave.push(index);
|
|
7755
|
-
cursor = i + 1;
|
|
7756
|
-
}
|
|
7757
|
-
// No more eligible candidates
|
|
7758
|
-
if (wave.length === 0) {
|
|
7759
|
-
break;
|
|
8354
|
+
.filter((entry) => entry.tokens >= minTokensForSummary && !results[entry.index].summary)
|
|
8355
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
8356
|
+
.map((entry) => entry.index);
|
|
8357
|
+
if (eligibleIndices.length === 0 || totalTokenCount <= maxTokens) {
|
|
8358
|
+
return { directories: results, totalTokenCount };
|
|
8359
|
+
}
|
|
8360
|
+
const limit = createLimit$1(maxConcurrent);
|
|
8361
|
+
logger.verbose(`\nProcessing ${eligibleIndices.length} directories with continuous queue (concurrency ${maxConcurrent})...`, { color: 'blue' });
|
|
8362
|
+
await Promise.all(eligibleIndices.map((idx) => limit(async () => {
|
|
8363
|
+
// Re-check the budget at dispatch time. Earlier completions
|
|
8364
|
+
// may have already dropped the total under the cap; in that
|
|
8365
|
+
// case skip the LLM call entirely.
|
|
8366
|
+
if (totalTokenCount <= maxTokens) {
|
|
8367
|
+
return;
|
|
7760
8368
|
}
|
|
7761
|
-
|
|
7762
|
-
|
|
7763
|
-
|
|
7764
|
-
|
|
7765
|
-
|
|
7766
|
-
|
|
7767
|
-
const originalTokens = results[idx].tokenCount;
|
|
7768
|
-
const newTokens = result.tokenCount;
|
|
7769
|
-
const reduction = originalTokens - newTokens;
|
|
7770
|
-
totalTokenCount -= reduction;
|
|
7771
|
-
results[idx] = result;
|
|
7772
|
-
logger.verbose(` • Summarized "/${result.path}": ${originalTokens} -> ${newTokens} tokens`, {
|
|
7773
|
-
color: 'magenta',
|
|
7774
|
-
});
|
|
8369
|
+
const result = await summarizeDirectoryDiff(results[idx], {
|
|
8370
|
+
chain,
|
|
8371
|
+
textSplitter,
|
|
8372
|
+
tokenizer,
|
|
8373
|
+
logger,
|
|
8374
|
+
metadata,
|
|
7775
8375
|
});
|
|
7776
|
-
|
|
7777
|
-
|
|
8376
|
+
const originalTokens = results[idx].tokenCount;
|
|
8377
|
+
const newTokens = result.tokenCount;
|
|
8378
|
+
totalTokenCount -= (originalTokens - newTokens);
|
|
8379
|
+
results[idx] = result;
|
|
8380
|
+
logger.verbose(` • Summarized "/${result.path}": ${originalTokens} -> ${newTokens} tokens`, {
|
|
8381
|
+
color: 'magenta',
|
|
7778
8382
|
});
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
7782
|
-
|
|
7783
|
-
}
|
|
7784
|
-
}
|
|
8383
|
+
})));
|
|
8384
|
+
logger.verbose(`Total token count after continuous queue: ${totalTokenCount}`, {
|
|
8385
|
+
color: totalTokenCount > maxTokens ? 'yellow' : 'green',
|
|
8386
|
+
});
|
|
7785
8387
|
return { directories: results, totalTokenCount };
|
|
7786
8388
|
}
|
|
8389
|
+
/**
|
|
8390
|
+
* Tiny semaphore mirroring `collectDiffs.createLimit` (kept private
|
|
8391
|
+
* here to avoid a cross-module import for one helper). Schedules at
|
|
8392
|
+
* most `maxConcurrent` operations concurrently; the rest queue FIFO.
|
|
8393
|
+
*/
|
|
8394
|
+
function createLimit$1(maxConcurrent) {
|
|
8395
|
+
const limit = Math.max(1, maxConcurrent);
|
|
8396
|
+
let active = 0;
|
|
8397
|
+
const queue = [];
|
|
8398
|
+
const runNext = () => {
|
|
8399
|
+
active--;
|
|
8400
|
+
const next = queue.shift();
|
|
8401
|
+
if (next)
|
|
8402
|
+
next();
|
|
8403
|
+
};
|
|
8404
|
+
return async (operation) => {
|
|
8405
|
+
if (active >= limit) {
|
|
8406
|
+
await new Promise((resolve) => queue.push(resolve));
|
|
8407
|
+
}
|
|
8408
|
+
active++;
|
|
8409
|
+
try {
|
|
8410
|
+
return await operation();
|
|
8411
|
+
}
|
|
8412
|
+
finally {
|
|
8413
|
+
runNext();
|
|
8414
|
+
}
|
|
8415
|
+
};
|
|
8416
|
+
}
|
|
7787
8417
|
/**
|
|
7788
8418
|
* Summarize diffs using a three-phase approach:
|
|
7789
8419
|
*
|
|
@@ -7797,7 +8427,16 @@ async function summarizeInWaves(directories, options) {
|
|
|
7797
8427
|
* - Efficient parallel processing with predictable behavior
|
|
7798
8428
|
* - Early exit when under token budget
|
|
7799
8429
|
*/
|
|
7800
|
-
async function summarizeDiffs(rootDiffNode, { tokenizer, logger,
|
|
8430
|
+
async function summarizeDiffs(rootDiffNode, { tokenizer, logger,
|
|
8431
|
+
// Default raised to 4096 (#845) so the budget matches the
|
|
8432
|
+
// canonical service configs in `langchain/utils.ts`. The
|
|
8433
|
+
// previous 2048 default came from an earlier era when 4k
|
|
8434
|
+
// context was a stretch for fast models; today every shipped
|
|
8435
|
+
// service overrides it to 4096 anyway. Keeping this in sync
|
|
8436
|
+
// with the service defaults means a caller that omits
|
|
8437
|
+
// `maxTokens` doesn't accidentally fall into a tighter budget
|
|
8438
|
+
// than the rest of the system assumes.
|
|
8439
|
+
maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
|
|
7801
8440
|
// Calculate maxFileTokens as 25% of maxTokens if not specified
|
|
7802
8441
|
const effectiveMaxFileTokens = maxFileTokens ?? Math.floor(maxTokens * 0.25);
|
|
7803
8442
|
// PHASE 1: Directory grouping & assessment
|
|
@@ -10816,10 +11455,17 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
|
|
|
10816
11455
|
// 1. Pre-process large files to prevent bias
|
|
10817
11456
|
// 2. Group by directory and assess token count
|
|
10818
11457
|
// 3. Wave-based parallel summarization until under budget
|
|
11458
|
+
//
|
|
11459
|
+
// The 4096 fallback (#845) matches the default service configs
|
|
11460
|
+
// for openai / anthropic / ollama (`langchain/utils.ts`). It's a
|
|
11461
|
+
// safety net for users with custom service definitions that omit
|
|
11462
|
+
// `tokenLimit` — without it those users hit a degenerate 2048
|
|
11463
|
+
// budget that triggers needless pre-summarization on diffs the
|
|
11464
|
+
// model could absorb whole.
|
|
10819
11465
|
logger.startTimer();
|
|
10820
11466
|
const summary = await summarizeDiffs(diffs, {
|
|
10821
11467
|
tokenizer,
|
|
10822
|
-
maxTokens: maxTokens ||
|
|
11468
|
+
maxTokens: maxTokens || 4096,
|
|
10823
11469
|
minTokensForSummary,
|
|
10824
11470
|
maxFileTokens,
|
|
10825
11471
|
maxConcurrent,
|
|
@@ -29235,6 +29881,7 @@ y.command(init.command, init.desc, init.builder, init.handler);
|
|
|
29235
29881
|
y.command(doctor.command, doctor.desc, doctor.builder, doctor.handler);
|
|
29236
29882
|
y.command(log.command, log.desc, log.builder, log.handler);
|
|
29237
29883
|
y.command(ui.command, ui.desc, ui.builder, ui.handler);
|
|
29884
|
+
y.command(cache.command, cache.desc, cache.builder, cache.handler);
|
|
29238
29885
|
y.help().parse(process.argv.slice(2));
|
|
29239
29886
|
|
|
29240
29887
|
/**
|
|
@@ -29686,4 +30333,4 @@ var commitValidationHandler = /*#__PURE__*/Object.freeze({
|
|
|
29686
30333
|
handleValidationErrors: handleValidationErrors
|
|
29687
30334
|
});
|
|
29688
30335
|
|
|
29689
|
-
export { changelog, commit, doctor, init, log, recap, types, ui };
|
|
30336
|
+
export { cache, changelog, commit, doctor, init, log, recap, types, ui };
|