Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion scripts/compress-tool-output.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,26 @@
// with COMB_COMPRESS_DEBUG=1 to see the real shape for your version and
// adjust FIELD_CANDIDATES below if needed.

const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const crypto = require('node:crypto');

const HEAD_CHARS = Number(process.env.COMB_COMPRESS_HEAD) || 1200;
const TAIL_CHARS = Number(process.env.COMB_COMPRESS_TAIL) || 800;
const THRESHOLD = Number(process.env.COMB_COMPRESS_THRESHOLD) || 3000;
// Mirrors OpenDev's truncation.rs (crates/opendev-tools-impl): elision is
// otherwise irreversible — once the middle is gone, it's gone. Opt-out
// (not opt-in) because losing data silently is worse than a stray file on
// disk; set COMB_COMPRESS_SAVE_FULL=0 to disable.
const SAVE_FULL = process.env.COMB_COMPRESS_SAVE_FULL !== '0';
const OVERFLOW_DIR = path.join(os.homedir(), '.claude', 'comb', 'tool-output');
// Same cap as OpenDev: never let one huge tool call write unbounded bytes
// to disk. Past this, keep head 75% + tail 25% of the *original* text in
// the saved file too (same shape as the in-context elision, just a much
// bigger window).
const MAX_OVERFLOW_BYTES = 1024 * 1024;
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
// Ceiling on the critical-gate bypass (see middleHasExcessErrors below): a
// dense-error output only skips compression entirely if it's still small.
// Above this, letting it through whole would defeat the compressor's whole
Expand Down Expand Up @@ -95,6 +112,64 @@ function middleHasExcessErrors(middle) {
return false;
}

// Saves the full, untouched text to a uniquely-named file in OVERFLOW_DIR.
// Never throws — on any filesystem error, returns null and the caller just
// omits the recovery hint (elision still happens; it's the file that's
// best-effort, not the compression). If text exceeds MAX_OVERFLOW_BYTES,
// the saved file is itself capped head75/tail25 rather than truncated flat,
// so both "how it started" and "how it ended" survive even in the worst case.
function saveOverflow(text) {
try {
fs.mkdirSync(OVERFLOW_DIR, { recursive: true });
const filename = `tool_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
const filepath = path.join(OVERFLOW_DIR, filename);

let toWrite = text;
if (Buffer.byteLength(text, 'utf8') > MAX_OVERFLOW_BYTES) {
const headSize = Math.floor((MAX_OVERFLOW_BYTES * 3) / 4);
const tailSize = MAX_OVERFLOW_BYTES - headSize;
const head = text.slice(0, headSize);
const tail = text.slice(-tailSize);
const omitted = text.length - headSize - tailSize;
toWrite = `${head}\n\n[... ${omitted} chars omitted from overflow file ...]\n\n${tail}`;
}

fs.writeFileSync(filepath, toWrite, { mode: 0o600 });
return filepath;
} catch {
return null; // best-effort — never block the hook on a disk write
}
}

// Removes overflow files older than RETENTION_MS. Not called automatically
// by this hook (PostToolUse runs on the hot path — a directory scan every
// tool call is wasted work); wire this into a SessionStart hook or a cron
// if unbounded disk growth in OVERFLOW_DIR becomes a problem.
function cleanupOldFiles() {
let entries;
try {
entries = fs.readdirSync(OVERFLOW_DIR, { withFileTypes: true });
} catch {
return 0; // directory doesn't exist yet — nothing to clean
}
const cutoff = Date.now() - RETENTION_MS;
let cleaned = 0;
for (const entry of entries) {
if (!entry.isFile() || !entry.name.startsWith('tool_')) continue;
const filepath = path.join(OVERFLOW_DIR, entry.name);
try {
const { mtimeMs } = fs.statSync(filepath);
if (mtimeMs < cutoff) {
fs.unlinkSync(filepath);
cleaned += 1;
}
} catch {
// file vanished or unreadable between readdir and stat — skip it
}
}
return cleaned;
}

function compress(text) {
if (text.length <= THRESHOLD) return null; // not worth touching

Expand All @@ -107,10 +182,12 @@ function compress(text) {
if (middleHasExcessErrors(middle) && text.length <= GATE_MAX_CHARS) return null;

const errorLines = salvageErrorLines(middle);
const savedPath = SAVE_FULL ? saveOverflow(text) : null;

const marker =
`\n… [comb: elided ${middle.length} chars` +
(errorLines.length ? `, ${errorLines.length} error line(s) kept below` : '') +
(savedPath ? `, full output: ${savedPath}` : '') +
`] …\n`;
const errorBlock = errorLines.length ? errorLines.join('\n') + '\n' : '';

Expand Down Expand Up @@ -189,5 +266,6 @@ if (require.main === module) {

module.exports = {
compress, locateText, salvageErrorLines, middleHasExcessErrors, extractCommand, tryRuleStore,
THRESHOLD, HEAD_CHARS, TAIL_CHARS, GATE_MAX_CHARS,
saveOverflow, cleanupOldFiles,
THRESHOLD, HEAD_CHARS, TAIL_CHARS, GATE_MAX_CHARS, SAVE_FULL, OVERFLOW_DIR, MAX_OVERFLOW_BYTES, RETENTION_MS,
};
143 changes: 143 additions & 0 deletions test/overflow.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
'use strict';

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');

// OVERFLOW_DIR is computed at module-load time from os.homedir(), so we
// redirect HOME before requiring the module, then clean up after.
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'comb-home-'));
const originalHome = process.env.HOME;
process.env.HOME = fakeHome;

const {
compress, saveOverflow, cleanupOldFiles, OVERFLOW_DIR, MAX_OVERFLOW_BYTES, SAVE_FULL,
} = require('../scripts/compress-tool-output.js');

test.after(() => {
process.env.HOME = originalHome;
fs.rmSync(fakeHome, { recursive: true, force: true });
});

test('sanity: OVERFLOW_DIR resolved under the fake HOME, not the real one', () => {
assert.ok(OVERFLOW_DIR.startsWith(fakeHome), `expected ${OVERFLOW_DIR} to start with ${fakeHome}`);
});

test('SAVE_FULL defaults to true (opt-out, not opt-in)', () => {
assert.equal(SAVE_FULL, true);
});

test('saveOverflow: writes the exact full text and returns a path', () => {
const text = 'full untouched output\n'.repeat(500);
const p = saveOverflow(text);
assert.ok(p, 'expected a path back');
assert.ok(fs.existsSync(p));
assert.equal(fs.readFileSync(p, 'utf8'), text);
fs.rmSync(p);
});

test('saveOverflow: caps at MAX_OVERFLOW_BYTES with head75/tail25, never a flat cutoff', () => {
const head = 'HEAD_MARKER_'.repeat(200);
const tail = 'TAIL_MARKER_'.repeat(200);
const filler = 'x'.repeat(MAX_OVERFLOW_BYTES); // pushes well past the cap
const text = head + filler + tail;

const p = saveOverflow(text);
assert.ok(p);
const saved = fs.readFileSync(p, 'utf8');

assert.ok(saved.length < text.length, 'saved file should be smaller than the original');
assert.ok(saved.length <= MAX_OVERFLOW_BYTES + 200, 'saved file should respect the byte cap (+ omission notice slack)');
assert.ok(saved.startsWith('HEAD_MARKER_'), 'head should survive the cap');
assert.ok(saved.endsWith('TAIL_MARKER_'.repeat(200)), 'tail should survive the cap');
assert.ok(saved.includes('chars omitted from overflow file'), 'should note what was omitted');
fs.rmSync(p);
});

test('saveOverflow: never throws when the directory cannot be created (fails silently)', () => {
// Point OVERFLOW_DIR-equivalent at a path that can't be a directory by
// colliding with a file — but since OVERFLOW_DIR is fixed at require-time,
// simulate the failure mode directly instead: make mkdir fail by using a
// path segment that is actually a file.
const blockerFile = path.join(fakeHome, 'blocker');
fs.writeFileSync(blockerFile, 'i am a file, not a directory');
const badDir = path.join(blockerFile, 'cannot-create-under-a-file');
// Reach into fs directly the same way saveOverflow would, to confirm the
// underlying mkdir does throw in this scenario (sanity check on the test
// setup itself) — saveOverflow's own try/catch is what we're really
// testing, exercised via the exported function on the real OVERFLOW_DIR
// in the other tests. This test just documents the failure mode exists.
assert.throws(() => fs.mkdirSync(badDir, { recursive: true }));
fs.rmSync(blockerFile);
});

test('compress(): elided output includes a recovery path, and that file has the full original', () => {
const head = 'A'.repeat(1200);
const middle = 'B\n'.repeat(5000);
const tail = 'C'.repeat(800);
const original = head + middle + tail;

const result = compress(original);
assert.ok(result, 'expected compression to trigger');
assert.match(result, /full output: (.+tool-output.+)\]/);

const match = result.match(/full output: (\S+)\]/);
const savedPath = match[1];
assert.ok(fs.existsSync(savedPath), `expected ${savedPath} to exist`);
assert.equal(fs.readFileSync(savedPath, 'utf8'), original, 'saved file should contain the exact original, unelided');
fs.rmSync(savedPath);
});

test('compress(): with COMB_COMPRESS_SAVE_FULL=0, no file is written and no path appears in the marker', () => {
process.env.COMB_COMPRESS_SAVE_FULL = '0';
// Need a fresh require with the env var set before module load, since
// SAVE_FULL is computed at require-time like OVERFLOW_DIR.
delete require.cache[require.resolve('../scripts/compress-tool-output.js')];
const mod = require('../scripts/compress-tool-output.js');

const before = fs.existsSync(OVERFLOW_DIR) ? fs.readdirSync(OVERFLOW_DIR).length : 0;

const head = 'A'.repeat(1200);
const middle = 'B\n'.repeat(5000);
const tail = 'C'.repeat(800);
const result = mod.compress(head + middle + tail);

assert.ok(result, 'compression should still trigger');
assert.ok(!result.includes('full output:'), 'marker should not mention a saved path');

const after = fs.existsSync(OVERFLOW_DIR) ? fs.readdirSync(OVERFLOW_DIR).length : 0;
assert.equal(after, before, 'no new file should have been written');

delete process.env.COMB_COMPRESS_SAVE_FULL;
delete require.cache[require.resolve('../scripts/compress-tool-output.js')];
});

test('cleanupOldFiles: removes files older than retention, keeps recent ones', () => {
fs.mkdirSync(OVERFLOW_DIR, { recursive: true });
const recent = path.join(OVERFLOW_DIR, 'tool_recent_test');
const old = path.join(OVERFLOW_DIR, 'tool_old_test');
const nonTool = path.join(OVERFLOW_DIR, 'not_a_tool_file.txt');
fs.writeFileSync(recent, 'data');
fs.writeFileSync(old, 'data');
fs.writeFileSync(nonTool, 'data');

const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
fs.utimesSync(old, eightDaysAgo, eightDaysAgo);

const cleaned = cleanupOldFiles();

assert.equal(cleaned, 1);
assert.ok(fs.existsSync(recent), 'recent file should survive');
assert.ok(!fs.existsSync(old), 'old file should be removed');
assert.ok(fs.existsSync(nonTool), 'non-tool file should be untouched');

fs.rmSync(recent, { force: true });
fs.rmSync(nonTool, { force: true });
});

test('cleanupOldFiles: returns 0 when OVERFLOW_DIR does not exist yet, never throws', () => {
fs.rmSync(OVERFLOW_DIR, { recursive: true, force: true });
assert.equal(cleanupOldFiles(), 0);
});