From e6a28a4571fed0e74829302851f917cf6a2bfe51 Mon Sep 17 00:00:00 2001 From: Griffen Fargo <3642037+gfargo@users.noreply.github.com> Date: Wed, 6 May 2026 01:13:24 -0400 Subject: [PATCH 1/2] feat(parser): content-hash diff-summary disk cache (#845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 5 of the #845 sprint. Adds a per-repo, content-hash-keyed disk cache for LLM-summarized diffs so re-runs of `coco commit` / `changelog` / `recap` / `review` skip the LLM entirely for files whose diffs haven't changed since the prior run. Cache key: `sha256(diff + model + SUMMARIZE_PROMPT_HASH)`. Switching models or editing the summarization prompt invalidates entries automatically. Storage lives at `$XDG_CACHE_HOME/coco/diff-summaries/summaries..json` with a 500-entry hard cap and LRU eviction. Best-effort throughout — read/write failures fall back to the LLM path, never load-bearing. Wired into both `summarizeFileDiff` (pre-process pass) and `summarizeDirectoryDiff` (wave consolidation). New `coco cache` command exposes `clear` and `info` subcommands. `COCO_NO_CACHE=1` opts out for users who want a guaranteed fresh run. Bench harness gains `--repeat` (cold pass + warm pass) and `--no-cache` flags. New baseline captures both passes. Bench numbers (cold → warm wall-clock): - medium: 8506ms → 4ms (99.95% faster) - large: 14417ms → 6ms (99.96%) - feature-add: 13064ms → 4ms (99.97%) - refactor: 51116ms → 6ms (99.99%) - initial-commit: 16967ms → 7ms (99.96%) - docs-update: 24384ms → 2ms (99.99%) - monorepo: 88958ms → 26ms (99.97%) 24 new tests (19 cache module + 5 cache command). Full suite: 5134 passing. --- .bench/baseline.json | 147 +++++++++++-- bin/benchmark.ts | 42 +++- src/commands/cache/config.ts | 19 ++ src/commands/cache/handler.test.ts | 73 ++++++ src/commands/cache/handler.ts | 84 +++++++ src/commands/cache/index.ts | 10 + src/index.ts | 11 +- src/lib/langchain/chains/summarize/prompt.ts | 16 ++ .../default/utils/diffSummaryCache.test.ts | 207 ++++++++++++++++++ .../parsers/default/utils/diffSummaryCache.ts | 181 +++++++++++++++ .../parsers/default/utils/summarizeDiffs.ts | 54 +++++ .../default/utils/summarizeLargeFiles.ts | 50 +++++ 12 files changed, 864 insertions(+), 30 deletions(-) create mode 100644 src/commands/cache/config.ts create mode 100644 src/commands/cache/handler.test.ts create mode 100644 src/commands/cache/handler.ts create mode 100644 src/commands/cache/index.ts create mode 100644 src/lib/parsers/default/utils/diffSummaryCache.test.ts create mode 100644 src/lib/parsers/default/utils/diffSummaryCache.ts diff --git a/.bench/baseline.json b/.bench/baseline.json index cd42cfd0..cbe26b88 100644 --- a/.bench/baseline.json +++ b/.bench/baseline.json @@ -1,5 +1,5 @@ { - "capturedAt": "2026-05-06T04:51:15.941Z", + "capturedAt": "2026-05-06T05:12:57.441Z", "node": "v22.13.0", "platform": "darwin-arm64", "options": { @@ -16,61 +16,148 @@ "durationMs": 2, "llmCalls": 0, "llmTotalMs": 0, - "llmTotalPromptTokens": 0 + "llmTotalPromptTokens": 0, + "pass": "cold" + }, + { + "fixture": "tiny", + "fileCount": 5, + "approxTokens": 790, + "durationMs": 1, + "llmCalls": 0, + "llmTotalMs": 0, + "llmTotalPromptTokens": 0, + "pass": "warm" }, { "fixture": "medium", "fileCount": 25, "approxTokens": 36150, - "durationMs": 8502, + "durationMs": 8505, "llmCalls": 6, - "llmTotalMs": 36155, - "llmTotalPromptTokens": 8525 + "llmTotalMs": 36162, + "llmTotalPromptTokens": 8525, + "pass": "cold" + }, + { + "fixture": "medium", + "fileCount": 25, + "approxTokens": 36150, + "durationMs": 4, + "llmCalls": 0, + "llmTotalMs": 0, + "llmTotalPromptTokens": 0, + "pass": "warm" }, { "fixture": "large", "fileCount": 50, "approxTokens": 83410, - "durationMs": 14413, + "durationMs": 14415, "llmCalls": 7, - "llmTotalMs": 55470, - "llmTotalPromptTokens": 17461 + "llmTotalMs": 55466, + "llmTotalPromptTokens": 17461, + "pass": "cold" + }, + { + "fixture": "large", + "fileCount": 50, + "approxTokens": 83410, + "durationMs": 6, + "llmCalls": 0, + "llmTotalMs": 0, + "llmTotalPromptTokens": 0, + "pass": "warm" }, { "fixture": "feature-add", "fileCount": 14, "approxTokens": 17600, - "durationMs": 13062, + "durationMs": 13074, "llmCalls": 4, - "llmTotalMs": 27557, - "llmTotalPromptTokens": 6117 + "llmTotalMs": 27556, + "llmTotalPromptTokens": 6117, + "pass": "cold" + }, + { + "fixture": "feature-add", + "fileCount": 14, + "approxTokens": 17600, + "durationMs": 4, + "llmCalls": 0, + "llmTotalMs": 0, + "llmTotalPromptTokens": 0, + "pass": "warm" }, { "fixture": "refactor", "fileCount": 30, "approxTokens": 32650, - "durationMs": 51116, + "durationMs": 51115, "llmCalls": 20, - "llmTotalMs": 187612, - "llmTotalPromptTokens": 53548 + "llmTotalMs": 187600, + "llmTotalPromptTokens": 53548, + "pass": "cold" + }, + { + "fixture": "refactor", + "fileCount": 30, + "approxTokens": 32650, + "durationMs": 5, + "llmCalls": 0, + "llmTotalMs": 0, + "llmTotalPromptTokens": 0, + "pass": "warm" }, { "fixture": "initial-commit", "fileCount": 50, "approxTokens": 83410, - "durationMs": 16965, + "durationMs": 16962, "llmCalls": 7, - "llmTotalMs": 57202, - "llmTotalPromptTokens": 17107 + "llmTotalMs": 57196, + "llmTotalPromptTokens": 17107, + "pass": "cold" + }, + { + "fixture": "initial-commit", + "fileCount": 50, + "approxTokens": 83410, + "durationMs": 5, + "llmCalls": 0, + "llmTotalMs": 0, + "llmTotalPromptTokens": 0, + "pass": "warm" }, { "fixture": "docs-update", "fileCount": 9, "approxTokens": 15050, - "durationMs": 24384, + "durationMs": 24385, "llmCalls": 7, - "llmTotalMs": 68468, - "llmTotalPromptTokens": 13139 + "llmTotalMs": 68473, + "llmTotalPromptTokens": 13139, + "pass": "cold" + }, + { + "fixture": "docs-update", + "fileCount": 9, + "approxTokens": 15050, + "durationMs": 3, + "llmCalls": 0, + "llmTotalMs": 0, + "llmTotalPromptTokens": 0, + "pass": "warm" + }, + { + "fixture": "dep-bump", + "fileCount": 2, + "approxTokens": 450, + "durationMs": 0, + "llmCalls": 0, + "llmTotalMs": 0, + "llmTotalPromptTokens": 0, + "pass": "cold" }, { "fixture": "dep-bump", @@ -79,16 +166,28 @@ "durationMs": 0, "llmCalls": 0, "llmTotalMs": 0, - "llmTotalPromptTokens": 0 + "llmTotalPromptTokens": 0, + "pass": "warm" }, { "fixture": "monorepo", "fileCount": 80, "approxTokens": 159320, - "durationMs": 88957, + "durationMs": 88956, "llmCalls": 80, - "llmTotalMs": 921786, - "llmTotalPromptTokens": 249565 + "llmTotalMs": 921782, + "llmTotalPromptTokens": 249565, + "pass": "cold" + }, + { + "fixture": "monorepo", + "fileCount": 80, + "approxTokens": 159320, + "durationMs": 29, + "llmCalls": 0, + "llmTotalMs": 0, + "llmTotalPromptTokens": 0, + "pass": "warm" } ] } \ No newline at end of file diff --git a/bin/benchmark.ts b/bin/benchmark.ts index 14c28a68..4ac433cf 100644 --- a/bin/benchmark.ts +++ b/bin/benchmark.ts @@ -32,6 +32,7 @@ import type { Document } from '@langchain/classic/document' import { fileChangeParser } from '../src/lib/parsers/default' import { summarizeDiffs } from '../src/lib/parsers/default/utils/summarizeDiffs' +import { clearDiffSummaryCache } from '../src/lib/parsers/default/utils/diffSummaryCache' import { allFixtures, DiffFixture } from '../src/lib/parsers/default/__fixtures__' import { Logger } from '../src/lib/utils/logger' import { getTokenCounter } from '../src/lib/utils/tokenizer' @@ -88,6 +89,8 @@ type BenchResult = { llmCalls: number llmTotalMs: number llmTotalPromptTokens: number + /** When this row is a warm-cache re-run, the cold result it amortized against. */ + pass?: 'cold' | 'warm' } /** @@ -190,13 +193,19 @@ function formatRow(label: string, value: string | number): string { function printSummary(results: BenchResult[], baseline?: BenchResult[]): void { console.log('\n=== diff-condensing benchmark ===\n') for (const result of results) { - console.log(`Fixture: ${result.fixture} (${result.fileCount} files, ~${result.approxTokens} tokens)`) + const passLabel = result.pass ? ` (${result.pass})` : '' + console.log(`Fixture: ${result.fixture}${passLabel} (${result.fileCount} files, ~${result.approxTokens} tokens)`) console.log(formatRow('wall-clock duration', `${result.durationMs}ms`)) console.log(formatRow('llm calls', result.llmCalls)) console.log(formatRow('llm total time', `${result.llmTotalMs}ms`)) console.log(formatRow('llm prompt tokens', result.llmTotalPromptTokens)) if (baseline) { - const prior = baseline.find((entry) => entry.fixture === result.fixture) + // For repeat runs, only diff cold pass against baseline so warm + // numbers don't muddy the headline regression check. + const matchPass = result.pass ?? undefined + const prior = baseline.find( + (entry) => entry.fixture === result.fixture && (entry.pass ?? undefined) === matchPass + ) if (prior) { const deltaPct = (n: number, p: number) => p === 0 ? 'n/a' : `${(((n - p) / p) * 100).toFixed(1)}%` @@ -246,6 +255,16 @@ async function main(): Promise { const args = process.argv.slice(2) const updateBaseline = args.includes('--update') const fixtureArg = args.find((arg) => arg.startsWith('--fixture='))?.split('=')[1] + // --repeat runs each fixture twice: a cold pass (cache cleared + // beforehand) and a warm pass (cache populated by the cold pass). + // Demonstrates the cache hit rate added in #845 PR 5 — same fixture, + // unchanged inputs, second run should be essentially free. + const repeat = args.includes('--repeat') + // --no-cache disables the diff-summary cache for the run. Useful + // for reproducing pre-PR-5 numbers against the same harness. + if (args.includes('--no-cache')) { + process.env.COCO_NO_CACHE = '1' + } const fixtures = fixtureArg ? allFixtures.filter((fixture) => fixture.name === fixtureArg) @@ -258,9 +277,22 @@ async function main(): Promise { const results: BenchResult[] = [] for (const fixture of fixtures) { - console.log(`Running fixture ${fixture.name}...`) - const result = await runFixture(fixture, DEFAULT_OPTIONS) - results.push(result) + if (repeat) { + // Cold pass: clear the cache for this repo first so the run + // can't piggyback on a prior bench. + clearDiffSummaryCache(process.cwd()) + console.log(`Running fixture ${fixture.name} (cold)...`) + const cold = await runFixture(fixture, DEFAULT_OPTIONS) + results.push({ ...cold, pass: 'cold' }) + + console.log(`Running fixture ${fixture.name} (warm)...`) + const warm = await runFixture(fixture, DEFAULT_OPTIONS) + results.push({ ...warm, pass: 'warm' }) + } else { + console.log(`Running fixture ${fixture.name}...`) + const result = await runFixture(fixture, DEFAULT_OPTIONS) + results.push(result) + } } const baseline = updateBaseline ? undefined : readBaseline() diff --git a/src/commands/cache/config.ts b/src/commands/cache/config.ts new file mode 100644 index 00000000..861bbef6 --- /dev/null +++ b/src/commands/cache/config.ts @@ -0,0 +1,19 @@ +import { Arguments, Argv } from 'yargs' +import { getCommandUsageHeader } from '../../lib/ui/helpers' +import { BaseCommandOptions } from '../types' + +export interface CacheOptions extends BaseCommandOptions {} + +export type CacheArgv = Arguments + +export const command = 'cache ' + +export const builder = (yargs: Argv) => { + return yargs + .positional('subcommand', { + describe: 'Cache action to run (clear, info)', + type: 'string', + choices: ['clear', 'info'] as const, + }) + .usage(getCommandUsageHeader(command)) +} diff --git a/src/commands/cache/handler.test.ts b/src/commands/cache/handler.test.ts new file mode 100644 index 00000000..cabd96f8 --- /dev/null +++ b/src/commands/cache/handler.test.ts @@ -0,0 +1,73 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +import { + diffSummaryKey, + getDiffSummaryCachePath, + writeDiffSummary, +} from '../../lib/parsers/default/utils/diffSummaryCache' +import { handler } from './handler' + +describe('coco cache ', () => { + let tmpRoot: string + let originalXdgCacheHome: string | undefined + let logger: { log: jest.Mock } + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'coco-cache-cmd-')) + originalXdgCacheHome = process.env.XDG_CACHE_HOME + process.env.XDG_CACHE_HOME = tmpRoot + logger = { log: jest.fn() } + }) + + afterEach(() => { + if (originalXdgCacheHome === undefined) { + delete process.env.XDG_CACHE_HOME + } else { + process.env.XDG_CACHE_HOME = originalXdgCacheHome + } + fs.rmSync(tmpRoot, { recursive: true, force: true }) + }) + + it('clear: removes the cache file when present', async () => { + const key = diffSummaryKey('diff', 'gpt', 'p') + writeDiffSummary(process.cwd(), key, { summary: 's', model: 'gpt', tokens: 5 }) + expect(fs.existsSync(getDiffSummaryCachePath(process.cwd()))).toBe(true) + + await handler({ subcommand: 'clear' } as never, logger as never) + + expect(fs.existsSync(getDiffSummaryCachePath(process.cwd()))).toBe(false) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('Cleared')) + }) + + it('clear: reports no-op when the cache is cold', async () => { + await handler({ subcommand: 'clear' } as never, logger as never) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('No diff-summary cache')) + }) + + it('info: reports entry count + on-disk size when warm', async () => { + const key = diffSummaryKey('diff', 'gpt', 'p') + writeDiffSummary(process.cwd(), key, { summary: 'summary text', model: 'gpt', tokens: 9 }) + + await handler({ subcommand: 'info' } as never, logger as never) + + const lines = logger.log.mock.calls.map((args) => args[0]).join('\n') + expect(lines).toContain('entries') + expect(lines).toContain('1') + expect(lines).toContain('summary tokens') + }) + + it('info: notes a missing cache instead of erroring', async () => { + await handler({ subcommand: 'info' } as never, logger as never) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('No diff-summary cache')) + }) + + it('rejects unknown subcommands and sets exit code', async () => { + const previousExit = process.exitCode + await handler({ subcommand: 'panic' } as never, logger as never) + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('Unknown cache subcommand')) + expect(process.exitCode).toBe(1) + process.exitCode = previousExit + }) +}) diff --git a/src/commands/cache/handler.ts b/src/commands/cache/handler.ts new file mode 100644 index 00000000..b4d8a5ab --- /dev/null +++ b/src/commands/cache/handler.ts @@ -0,0 +1,84 @@ +import * as fs from 'node:fs' + +import chalk from 'chalk' + +import { + clearDiffSummaryCache, + getDiffSummaryCachePath, +} from '../../lib/parsers/default/utils/diffSummaryCache' +import { CommandHandler } from '../../lib/types' +import { CacheArgv } from './config' + +type CacheEntry = { + summary: string + model: string + tokens: number + lastAccessedAt: string +} + +type CacheEnvelope = { + version: number + savedAt: string + entries: Record +} + +function readEnvelopeOrUndefined(filePath: string): CacheEnvelope | undefined { + try { + if (!fs.existsSync(filePath)) return undefined + const raw = fs.readFileSync(filePath, 'utf8') + return JSON.parse(raw) as CacheEnvelope + } catch { + return undefined + } +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / 1024 / 1024).toFixed(2)} MB` +} + +export const handler: CommandHandler = async (argv, logger) => { + const subcommand = (argv as { subcommand?: string }).subcommand + const repoPath = process.cwd() + const cachePath = getDiffSummaryCachePath(repoPath) + + if (subcommand === 'clear') { + const result = clearDiffSummaryCache(repoPath) + if (!result.ok) { + logger.log(chalk.red(`Failed to clear diff-summary cache at ${cachePath}`)) + process.exitCode = 1 + return + } + if (result.removed) { + logger.log(chalk.green(`Cleared diff-summary cache at ${cachePath}`)) + } else { + logger.log(chalk.dim(`No diff-summary cache to clear (${cachePath})`)) + } + return + } + + if (subcommand === 'info') { + const envelope = readEnvelopeOrUndefined(cachePath) + if (!envelope) { + logger.log(chalk.dim(`No diff-summary cache for this repo (${cachePath})`)) + return + } + const stat = fs.statSync(cachePath) + const entryCount = Object.keys(envelope.entries).length + const totalSummaryTokens = Object.values(envelope.entries).reduce( + (sum, entry) => sum + entry.tokens, + 0 + ) + logger.log(chalk.bold('Diff-summary cache') + ` ${chalk.dim(cachePath)}`) + logger.log(` ${chalk.green('entries')} ${entryCount}`) + logger.log(` ${chalk.green('on-disk size')} ${formatBytes(stat.size)}`) + logger.log(` ${chalk.green('summary tokens')} ${totalSummaryTokens}`) + logger.log(` ${chalk.green('last saved')} ${envelope.savedAt}`) + return + } + + logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`)) + logger.log(chalk.dim('Use one of: clear, info')) + process.exitCode = 1 +} diff --git a/src/commands/cache/index.ts b/src/commands/cache/index.ts new file mode 100644 index 00000000..41793b2e --- /dev/null +++ b/src/commands/cache/index.ts @@ -0,0 +1,10 @@ +import commandExecutor from '../../lib/utils/commandExecutor' +import { builder, command } from './config' +import { handler } from './handler' + +export default { + command, + desc: 'Manage the diff-summary cache (clear, info)', + builder, + handler: commandExecutor(handler), +} diff --git a/src/index.ts b/src/index.ts index 804568cd..16acb0bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import yargs from 'yargs' +import cache from './commands/cache' import changelog from './commands/changelog' import commit from './commands/commit' import doctor from './commands/doctor' @@ -9,6 +10,7 @@ import recap from './commands/recap' import review from './commands/review' import ui from './commands/ui' +import { CacheOptions } from './commands/cache/config' import { ChangelogOptions } from './commands/changelog/config' import { CommitOptions } from './commands/commit/config' import { DoctorOptions } from './commands/doctor/config' @@ -80,6 +82,13 @@ y.command( ui.handler ) +y.command( + cache.command, + cache.desc, + cache.builder, + cache.handler +) + y.help().parse(process.argv.slice(2)) -export { changelog, commit, Config, doctor, init, log, recap, types, ui } +export { cache, changelog, commit, Config, doctor, init, log, recap, types, ui } diff --git a/src/lib/langchain/chains/summarize/prompt.ts b/src/lib/langchain/chains/summarize/prompt.ts index 4f0b196e..5c557be2 100644 --- a/src/lib/langchain/chains/summarize/prompt.ts +++ b/src/lib/langchain/chains/summarize/prompt.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto' + import { PromptTemplate } from '@langchain/core/prompts' /** @@ -24,3 +26,17 @@ export const SUMMARIZE_PROMPT = new PromptTemplate({ inputVariables, template, }) + +/** + * Stable fingerprint of the active summarization template (#845, PR 5). + * + * The diff-summary cache keys include this hash so any prompt edit + * invalidates prior cache entries automatically — no manual bumps, + * no stale outputs that no longer reflect the current prompt's voice + * or rules. Only the template body matters; whitespace differences + * still re-key the cache, which is the safe default. + */ +export const SUMMARIZE_PROMPT_HASH = createHash('sha256') + .update(template) + .digest('hex') + .slice(0, 16) diff --git a/src/lib/parsers/default/utils/diffSummaryCache.test.ts b/src/lib/parsers/default/utils/diffSummaryCache.test.ts new file mode 100644 index 00000000..3b09c6e9 --- /dev/null +++ b/src/lib/parsers/default/utils/diffSummaryCache.test.ts @@ -0,0 +1,207 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +import { + __testInternals, + clearDiffSummaryCache, + diffSummaryKey, + getDiffSummaryCachePath, + readDiffSummary, + touchDiffSummary, + writeDiffSummary, +} from './diffSummaryCache' + +describe('diffSummaryCache (#845, PR 5)', () => { + let tmpRoot: string + let originalXdgCacheHome: string | undefined + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'coco-diff-cache-')) + originalXdgCacheHome = process.env.XDG_CACHE_HOME + process.env.XDG_CACHE_HOME = tmpRoot + }) + + afterEach(() => { + if (originalXdgCacheHome === undefined) { + delete process.env.XDG_CACHE_HOME + } else { + process.env.XDG_CACHE_HOME = originalXdgCacheHome + } + fs.rmSync(tmpRoot, { recursive: true, force: true }) + }) + + describe('diffSummaryKey', () => { + it('produces identical keys for the same (diff, model, promptHash)', () => { + const a = diffSummaryKey('diff body', 'gpt-4.1-nano', 'p1') + const b = diffSummaryKey('diff body', 'gpt-4.1-nano', 'p1') + expect(a).toBe(b) + }) + + it('different diff text → different key', () => { + const a = diffSummaryKey('one', 'gpt-4.1-nano', 'p1') + const b = diffSummaryKey('two', 'gpt-4.1-nano', 'p1') + expect(a).not.toBe(b) + }) + + it('different model → different key (so model swaps invalidate cache)', () => { + const a = diffSummaryKey('diff', 'gpt-4.1-nano', 'p1') + const b = diffSummaryKey('diff', 'claude-haiku-4-5', 'p1') + expect(a).not.toBe(b) + }) + + it('different promptHash → different key (so prompt edits invalidate cache)', () => { + const a = diffSummaryKey('diff', 'gpt-4.1-nano', 'p1') + const b = diffSummaryKey('diff', 'gpt-4.1-nano', 'p2') + expect(a).not.toBe(b) + }) + }) + + describe('getDiffSummaryCachePath', () => { + it('lives under $XDG_CACHE_HOME/coco/diff-summaries', () => { + const cachePath = getDiffSummaryCachePath('/repo/foo') + expect(cachePath.startsWith(path.join(tmpRoot, 'coco', 'diff-summaries'))).toBe(true) + expect(cachePath).toMatch(/summaries\.[a-f0-9]{16}\.json$/) + }) + + it('different repo paths → different cache files', () => { + expect(getDiffSummaryCachePath('/repo/a')).not.toBe(getDiffSummaryCachePath('/repo/b')) + }) + + it('falls back to ~/.cache/coco when XDG_CACHE_HOME is unset', () => { + delete process.env.XDG_CACHE_HOME + expect(getDiffSummaryCachePath('/repo/x')) + .toMatch(new RegExp(`^${os.homedir()}/.cache/coco/diff-summaries/`)) + }) + }) + + describe('write + read round-trip', () => { + it('returns undefined on a cold cache', () => { + const key = diffSummaryKey('diff', 'gpt', 'p') + expect(readDiffSummary('/repo/foo', key)).toBeUndefined() + }) + + it('round-trips an entry with model + tokens preserved', () => { + const key = diffSummaryKey('diff body', 'gpt-4.1-nano', 'p') + writeDiffSummary('/repo/foo', key, { + summary: 'Added foo function', + model: 'gpt-4.1-nano', + tokens: 12, + }) + const read = readDiffSummary('/repo/foo', key) + expect(read?.summary).toBe('Added foo function') + expect(read?.model).toBe('gpt-4.1-nano') + expect(read?.tokens).toBe(12) + expect(read?.lastAccessedAt).toBeDefined() + }) + + it('different repos do not pollute each other', () => { + const key = diffSummaryKey('diff', 'gpt', 'p') + writeDiffSummary('/repo/foo', key, { summary: 'foo summary', model: 'gpt', tokens: 5 }) + writeDiffSummary('/repo/bar', key, { summary: 'bar summary', model: 'gpt', tokens: 5 }) + expect(readDiffSummary('/repo/foo', key)?.summary).toBe('foo summary') + expect(readDiffSummary('/repo/bar', key)?.summary).toBe('bar summary') + }) + + it('overwrites a prior entry on subsequent writes', () => { + const key = diffSummaryKey('diff', 'gpt', 'p') + writeDiffSummary('/repo/x', key, { summary: 'first', model: 'gpt', tokens: 1 }) + writeDiffSummary('/repo/x', key, { summary: 'second', model: 'gpt', tokens: 2 }) + expect(readDiffSummary('/repo/x', key)?.summary).toBe('second') + }) + }) + + describe('LRU eviction at hard cap', () => { + it('evicts the oldest entries when count exceeds the hard cap', () => { + const cap = __testInternals.CACHE_ENTRY_HARD_CAP + // Write cap + 5 entries with stale lastAccessedAt timestamps so the + // ordering is deterministic. Use the helper directly to avoid the + // wall-clock timestamp updates. + const entries: Record & { lastAccessedAt: string }> = {} + for (let i = 0; i < cap + 5; i++) { + entries[`key-${i}`] = { + summary: `s${i}`, + model: 'gpt', + tokens: 1, + lastAccessedAt: new Date(2026, 0, 1, 0, 0, i).toISOString(), + } as never + } + const evicted = __testInternals.enforceHardCap(entries as never) + expect(evicted).toHaveLength(5) + // Oldest 5 should be the first 5 we created. + expect(evicted).toEqual([ + 'key-0', + 'key-1', + 'key-2', + 'key-3', + 'key-4', + ]) + }) + + it('returns empty array when under the cap', () => { + const entries: Record & { lastAccessedAt: string }> = {} + for (let i = 0; i < 10; i++) { + entries[`key-${i}`] = { + summary: `s${i}`, + model: 'gpt', + tokens: 1, + lastAccessedAt: new Date().toISOString(), + } as never + } + expect(__testInternals.enforceHardCap(entries as never)).toEqual([]) + }) + }) + + describe('touchDiffSummary', () => { + it('updates lastAccessedAt on an existing entry', async () => { + const key = diffSummaryKey('diff', 'gpt', 'p') + writeDiffSummary('/repo/x', key, { summary: 's', model: 'gpt', tokens: 1 }) + const before = readDiffSummary('/repo/x', key)?.lastAccessedAt + await new Promise((resolve) => setTimeout(resolve, 10)) + touchDiffSummary('/repo/x', key) + const after = readDiffSummary('/repo/x', key)?.lastAccessedAt + expect(after).not.toBe(before) + expect(Date.parse(after as string)).toBeGreaterThan(Date.parse(before as string)) + }) + + it('is a no-op for missing entries', () => { + expect(() => touchDiffSummary('/repo/foo', 'unknown-key')).not.toThrow() + }) + }) + + describe('clearDiffSummaryCache', () => { + it('removes the cache file for the repo', () => { + const key = diffSummaryKey('diff', 'gpt', 'p') + writeDiffSummary('/repo/foo', key, { summary: 's', model: 'gpt', tokens: 1 }) + expect(fs.existsSync(getDiffSummaryCachePath('/repo/foo'))).toBe(true) + const result = clearDiffSummaryCache('/repo/foo') + expect(result).toEqual({ ok: true, removed: true }) + expect(fs.existsSync(getDiffSummaryCachePath('/repo/foo'))).toBe(false) + }) + + it('returns removed=false when the cache file did not exist', () => { + expect(clearDiffSummaryCache('/repo/never-cached')).toEqual({ ok: true, removed: false }) + }) + }) + + describe('robustness', () => { + it('returns undefined on a corrupt cache file', () => { + const cachePath = getDiffSummaryCachePath('/repo/corrupt') + fs.mkdirSync(path.dirname(cachePath), { recursive: true }) + fs.writeFileSync(cachePath, 'not valid json') + const key = diffSummaryKey('diff', 'gpt', 'p') + expect(readDiffSummary('/repo/corrupt', key)).toBeUndefined() + }) + + it('returns undefined on a schema-version mismatch', () => { + const cachePath = getDiffSummaryCachePath('/repo/oldschema') + fs.mkdirSync(path.dirname(cachePath), { recursive: true }) + fs.writeFileSync(cachePath, JSON.stringify({ + version: 999, + savedAt: new Date().toISOString(), + entries: { 'some-key': { summary: 's', model: 'gpt', tokens: 1, lastAccessedAt: '' } }, + })) + expect(readDiffSummary('/repo/oldschema', 'some-key')).toBeUndefined() + }) + }) +}) diff --git a/src/lib/parsers/default/utils/diffSummaryCache.ts b/src/lib/parsers/default/utils/diffSummaryCache.ts new file mode 100644 index 00000000..6ccdee9f --- /dev/null +++ b/src/lib/parsers/default/utils/diffSummaryCache.ts @@ -0,0 +1,181 @@ +import * as crypto from 'node:crypto' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +/** + * Per-repo disk cache of LLM-summarized diffs (#845, PR 5). On a + * re-run of `coco commit` after a small change, most files have + * unchanged content and unchanged diffs — caching their summaries + * by content hash means the second run skips the LLM entirely for + * those files and only pays for what's actually different. + * + * Strict best-effort: read failures fall back to "no cache" (the + * pipeline runs the LLM as before), and write failures are + * swallowed silently. The cache is never load-bearing. + * + * Repos are keyed by a short hash of their absolute path. No PII + * in the cache filename, and re-creating a repo at the same path + * keeps the same cache. + * + * Cache key: `sha256(diff + ':' + model + ':' + promptHash)`. + * - diff: the literal diff text being summarized + * - model: switching models invalidates (different summaries) + * - promptHash: editing the SUMMARIZE_PROMPT template invalidates + * + * Cap: 500 entries per repo. LRU eviction on overflow keeps the + * cache file under ~500 KB on a typical repo (each entry is a + * sha256 hash + 200-500-byte summary). + */ + +const CACHE_SCHEMA_VERSION = 1 +const CACHE_DIR_NAME = 'diff-summaries' +const CACHE_ENTRY_HARD_CAP = 500 + +export type DiffSummaryCacheEntry = { + summary: string + model: string + tokens: number + /** ISO timestamp; drives LRU eviction. */ + lastAccessedAt: string +} + +type CacheEnvelope = { + version: number + savedAt: string + entries: Record +} + +function resolveCacheDir(): string { + const xdg = process.env.XDG_CACHE_HOME + if (xdg && xdg.trim().length > 0) { + return path.join(xdg, 'coco', CACHE_DIR_NAME) + } + return path.join(os.homedir(), '.cache', 'coco', CACHE_DIR_NAME) +} + +function repoKey(repoPath: string): string { + // sha1 here is a non-security cache-key derivation — deterministic + // short identifier for the cache filename so two repos at different + // paths never collide. No PII or auth context is hashed; no + // collision-resistance against an adversary is required. + // DevSkim: ignore DS126858 + return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16) +} + +export function getDiffSummaryCachePath(repoPath: string): string { + return path.join(resolveCacheDir(), `summaries.${repoKey(repoPath)}.json`) +} + +/** + * Build the cache key for a (diff, model, prompt) tuple. sha256 + * because we want a strong content-hash; the per-entry storage cost + * is dominated by the summary text anyway. + */ +export function diffSummaryKey(diff: string, model: string, promptHash: string): string { + return crypto + .createHash('sha256') + .update(`${diff}\x1f${model}\x1f${promptHash}`) + .digest('hex') +} + +function readEnvelope(filePath: string): CacheEnvelope | undefined { + try { + const raw = fs.readFileSync(filePath, 'utf8') + const parsed = JSON.parse(raw) as CacheEnvelope + if (parsed.version !== CACHE_SCHEMA_VERSION) return undefined + if (!parsed.entries || typeof parsed.entries !== 'object') return undefined + return parsed + } catch { + return undefined + } +} + +export function readDiffSummary( + repoPath: string, + key: string +): DiffSummaryCacheEntry | undefined { + const envelope = readEnvelope(getDiffSummaryCachePath(repoPath)) + if (!envelope) return undefined + const entry = envelope.entries[key] + if (!entry) return undefined + return entry +} + +export function writeDiffSummary( + repoPath: string, + key: string, + entry: Omit +): void { + const filePath = getDiffSummaryCachePath(repoPath) + const existing = readEnvelope(filePath) || { + version: CACHE_SCHEMA_VERSION, + savedAt: new Date().toISOString(), + entries: {}, + } + existing.entries[key] = { ...entry, lastAccessedAt: new Date().toISOString() } + existing.savedAt = new Date().toISOString() + + const evictedEntries = enforceHardCap(existing.entries) + if (evictedEntries.length > 0) { + for (const evicted of evictedEntries) { + delete existing.entries[evicted] + } + } + + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, JSON.stringify(existing)) + } catch { + // Best-effort persistence; swallow. + } +} + +/** + * Touch an existing entry's lastAccessedAt so LRU eviction prefers + * dropping older / unused entries. Caller is expected to know the + * entry exists (read returned a hit). + */ +export function touchDiffSummary(repoPath: string, key: string): void { + const filePath = getDiffSummaryCachePath(repoPath) + const envelope = readEnvelope(filePath) + if (!envelope || !envelope.entries[key]) return + envelope.entries[key] = { + ...envelope.entries[key], + lastAccessedAt: new Date().toISOString(), + } + envelope.savedAt = new Date().toISOString() + try { + fs.writeFileSync(filePath, JSON.stringify(envelope)) + } catch { + // Swallow. + } +} + +function enforceHardCap(entries: Record): string[] { + const keys = Object.keys(entries) + if (keys.length <= CACHE_ENTRY_HARD_CAP) return [] + // Sort by lastAccessedAt ascending (oldest first), drop the + // oldest (keys.length - CACHE_ENTRY_HARD_CAP) entries. + const sorted = keys + .map((key) => ({ key, accessed: Date.parse(entries[key].lastAccessedAt) || 0 })) + .sort((a, b) => a.accessed - b.accessed) + const toEvict = sorted.slice(0, keys.length - CACHE_ENTRY_HARD_CAP).map((entry) => entry.key) + return toEvict +} + +/** Remove the entire cache file for the repo. Used by `coco cache:clear`. */ +export function clearDiffSummaryCache(repoPath: string): { ok: boolean; removed: boolean } { + const filePath = getDiffSummaryCachePath(repoPath) + if (!fs.existsSync(filePath)) { + return { ok: true, removed: false } + } + try { + fs.unlinkSync(filePath) + return { ok: true, removed: true } + } catch { + return { ok: false, removed: false } + } +} + +export const __testInternals = { CACHE_ENTRY_HARD_CAP, enforceHardCap } diff --git a/src/lib/parsers/default/utils/summarizeDiffs.ts b/src/lib/parsers/default/utils/summarizeDiffs.ts index c08d2980..8357ec4f 100644 --- a/src/lib/parsers/default/utils/summarizeDiffs.ts +++ b/src/lib/parsers/default/utils/summarizeDiffs.ts @@ -2,9 +2,24 @@ import { DirectoryDiff, DiffNode } from '../../../types' import { Logger } from '../../../utils/logger' import { getPathFromFilePath } from '../../../utils/getPathFromFilePath' import { SummarizeContext, summarize } from '../../../langchain/chains/summarize' +import { SUMMARIZE_PROMPT_HASH } from '../../../langchain/chains/summarize/prompt' import { TokenCounter } from '../../../utils/tokenizer' +import { + diffSummaryKey, + readDiffSummary, + touchDiffSummary, + writeDiffSummary, +} from './diffSummaryCache' import { preprocessLargeFiles } from './summarizeLargeFiles' +/** + * Cache opt-out: COCO_NO_CACHE=1 disables both reads and writes + * for the diff-summary cache (#845, PR 5). Default is enabled. + */ +function isCacheEnabled(): boolean { + return !process.env.COCO_NO_CACHE || process.env.COCO_NO_CACHE === '0' +} + /** * Create groups from a given node info. * @param {DiffNode} node - The node info to start grouping. @@ -42,6 +57,37 @@ export async function summarizeDirectoryDiff( directory: DirectoryDiff, { chain, textSplitter, tokenizer, logger, metadata }: SummarizeDirectoryDiffOptions ): Promise { + // Cache lookup (#845, PR 5). Joined per-file diffs become the + // payload signature; if every file in the directory is unchanged + // since the last run (and the model + prompt match), the prior + // directory-level summary is reused instead of paying for another + // map_reduce pass. + const cacheModel = typeof metadata?.model === 'string' ? metadata.model : undefined + const cacheRepo = process.cwd() + const cachePayload = directory.diffs + .map((diff) => `${diff.file}\x1e${diff.diff}`) + .join('\x1d') + const cacheKey = isCacheEnabled() && cacheModel + ? diffSummaryKey(cachePayload, cacheModel, SUMMARIZE_PROMPT_HASH) + : undefined + + if (cacheKey) { + const cached = readDiffSummary(cacheRepo, cacheKey) + if (cached) { + logger?.verbose?.( + ` • Cache hit for "/${directory.path}" (skipped LLM, ${cached.tokens} tokens)`, + { color: 'cyan' } + ) + touchDiffSummary(cacheRepo, cacheKey) + return { + diffs: directory.diffs, + path: directory.path, + summary: cached.summary, + tokenCount: cached.tokens, + } + } + } + try { const directorySummary = await summarize( directory.diffs.map((diff) => ({ @@ -68,6 +114,14 @@ export async function summarizeDirectoryDiff( const newTokenTotal = tokenizer(directorySummary) + if (cacheKey && cacheModel) { + writeDiffSummary(cacheRepo, cacheKey, { + summary: directorySummary, + model: cacheModel, + tokens: newTokenTotal, + }) + } + return { diffs: directory.diffs, path: directory.path, diff --git a/src/lib/parsers/default/utils/summarizeLargeFiles.ts b/src/lib/parsers/default/utils/summarizeLargeFiles.ts index 1869f1fa..95bc41e3 100644 --- a/src/lib/parsers/default/utils/summarizeLargeFiles.ts +++ b/src/lib/parsers/default/utils/summarizeLargeFiles.ts @@ -1,9 +1,24 @@ import { FileDiff, DiffNode } from '../../../types' import { SummarizeContext, summarize } from '../../../langchain/chains/summarize' +import { SUMMARIZE_PROMPT_HASH } from '../../../langchain/chains/summarize/prompt' import { TokenCounter } from '../../../utils/tokenizer' import { Logger } from '../../../utils/logger' +import { + diffSummaryKey, + readDiffSummary, + touchDiffSummary, + writeDiffSummary, +} from './diffSummaryCache' import { summarizeTrivialDiff } from './trivialDiff' +/** + * Cache opt-out: COCO_NO_CACHE=1 disables both reads and writes + * for the diff-summary cache (#845, PR 5). Default is enabled. + */ +function isCacheEnabled(): boolean { + return !process.env.COCO_NO_CACHE || process.env.COCO_NO_CACHE === '0' +} + export type SummarizeLargeFilesOptions = { /** * Maximum tokens allowed for a single file before it gets pre-summarized. @@ -55,6 +70,33 @@ async function summarizeFileDiff( } } + // Cache lookup (#845, PR 5). Keyed on the file's literal diff + // content + the active model + the summarization prompt hash. + // A hit returns the prior summary instantly; on iterative + // `coco commit` re-runs after small edits, the unchanged files + // never go to the LLM. + const cacheModel = typeof metadata?.model === 'string' ? metadata.model : undefined + const cacheRepo = process.cwd() + const cacheKey = isCacheEnabled() && cacheModel + ? diffSummaryKey(fileDiff.diff, cacheModel, SUMMARIZE_PROMPT_HASH) + : undefined + + if (cacheKey) { + const cached = readDiffSummary(cacheRepo, cacheKey) + if (cached) { + logger.verbose( + ` - ${fileDiff.file}: cache hit (skipped LLM, ${cached.tokens} tokens)`, + { color: 'cyan' } + ) + touchDiffSummary(cacheRepo, cacheKey) + return { + ...fileDiff, + diff: cached.summary, + tokenCount: cached.tokens, + } + } + } + try { const fileSummary = await summarize( [ @@ -83,6 +125,14 @@ async function summarizeFileDiff( const newTokenCount = tokenizer(fileSummary) + if (cacheKey && cacheModel) { + writeDiffSummary(cacheRepo, cacheKey, { + summary: fileSummary, + model: cacheModel, + tokens: newTokenCount, + }) + } + return { ...fileDiff, diff: fileSummary, From 01aa5e13a0e054a7bea8b2c99dbbedca1e890b9c Mon Sep 17 00:00:00 2001 From: Griffen Fargo <3642037+gfargo@users.noreply.github.com> Date: Wed, 6 May 2026 09:08:17 -0400 Subject: [PATCH 2/2] fix(parser): use sha256 for repo-key derivation to clear DevSkim DS126858 The repo-key is just a 16-char filename suffix; it never needed sha1 specifically. Switching to sha256 + truncate keeps the same behavior (deterministic short identifier, same length on disk) and clears the DevSkim weak-hash alert without an inline suppression. --- src/lib/parsers/default/utils/diffSummaryCache.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/lib/parsers/default/utils/diffSummaryCache.ts b/src/lib/parsers/default/utils/diffSummaryCache.ts index 6ccdee9f..b4a819f8 100644 --- a/src/lib/parsers/default/utils/diffSummaryCache.ts +++ b/src/lib/parsers/default/utils/diffSummaryCache.ts @@ -55,12 +55,11 @@ function resolveCacheDir(): string { } function repoKey(repoPath: string): string { - // sha1 here is a non-security cache-key derivation — deterministic + // sha256 here is a non-security cache-key derivation — deterministic // short identifier for the cache filename so two repos at different - // paths never collide. No PII or auth context is hashed; no - // collision-resistance against an adversary is required. - // DevSkim: ignore DS126858 - return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16) + // paths never collide. We truncate to 16 chars; collision-resistance + // against an adversary is not required. + return crypto.createHash('sha256').update(repoPath).digest('hex').slice(0, 16) } export function getDiffSummaryCachePath(repoPath: string): string {