fix(suggest-compact): don't quote a percentage against an assumed window

The context signal always rendered "N% of <window> window", including when
the window size was the assumed 200k default rather than a detected value.
On a 1M session whose transcript carries no [1m] marker, that produced
lines like:

  [StrategicCompact] Context ~194k tokens (97% of 200k window)

while actual usage was ~19%. The user compacts on a false alarm, loses
context, and the resulting quality drop reads as a model regression.

The gap is structural: the context threshold defaults to 80% of the
window (160k on 200k), so the signal fires precisely in the 160k-200k
band where the size cannot be determined — above 200k the observed-tokens
fallback correctly infers 1M, and below 160k nothing fires.

Model id alone cannot close this. A tier may ship both a 200k and a 1M
variant under one id, so neither the known-family table nor a new entry
can distinguish them, and the transcript records no window field.

So stop asserting what isn't known: resolveContextWindow() now reports
whether the size was detected (env override, [1m] marker, known family,
or observed tokens > 200k) or assumed, and the hook omits the percentage
and window label when it was assumed. The token count, threshold, and
firing behaviour are unchanged.

resolveContextWindowTokens() keeps its existing signature and semantics.

Note: 3 pre-existing failures in tests/hooks/suggest-compact.test.js
reproduce identically on unmodified main and are untouched here.
This commit is contained in:
Tanel
2026-08-29 14:55:13 -04:00
committed by haelyra
parent e82e477034
commit ecdd517765
3 changed files with 82 additions and 15 deletions
+9 -3
View File
@@ -38,7 +38,8 @@ const {
resolveContextThreshold,
resolveContextInterval,
computeContextBucket,
formatWindowLabel
formatWindowLabel,
isContextWindowInferred
} = require('../lib/transcript-context');
const COUNTER_FILE_PREFIX = 'claude-tool-count-';
@@ -185,8 +186,13 @@ function buildContextSuggestion(transcriptPath, bucketFile, env) {
writeFile(bucketFile, String(bucket));
const approxTokens = `${Math.round(usage.tokens / 1000)}k`;
const percent = Math.round((usage.tokens / windowTokens) * 100);
return `[StrategicCompact] Context ~${approxTokens} tokens (${percent}% of ${formatWindowLabel(windowTokens)} window) - consider /compact at the next logical boundary`;
// Only quote a percentage when the window size was actually detected.
// Against an assumed 200k default the denominator is a guess, and a
// "97% of 200k window" line on a 1M session triggers needless compaction.
const scale = isContextWindowInferred(usage.tokens, usage.model)
? ''
: ` (${Math.round((usage.tokens / windowTokens) * 100)}% of ${formatWindowLabel(windowTokens)} window)`;
return `[StrategicCompact] Context ~${approxTokens} tokens${scale} - consider /compact at the next logical boundary`;
} catch (err) {
log(`[StrategicCompact] Context signal skipped: ${err.message}`);
return null;
+40 -11
View File
@@ -158,24 +158,29 @@ function readLatestContextTokens(transcriptPath, options = {}) {
}
/**
* Detect the context window size for a turn.
* 1M when the model id carries the `[1m]` marker, matches a known large-window
* model family, or when the observed token count already exceeds the standard
* 200k window (covers logs that drop the suffix); otherwise the standard 200k
* window.
* Detect the context window size for a turn, and report whether that size was
* positively detected or merely assumed.
*
* `inferred: false` means the size came from evidence — an explicit env
* override, the `[1m]` marker, a known large-window family, or an observed
* token count that already exceeds the standard window. `inferred: true` means
* every check fell through and the standard 200k default was assumed; the
* window may actually be larger and callers must not present it as fact.
*
* @returns {{ windowTokens: number, inferred: boolean }}
*/
function resolveContextWindowTokens(tokens, model) {
function resolveContextWindow(tokens, model) {
// Explicit window override wins: 400k models (e.g. Opus 4.x) match neither the
// 200k default nor the 1M marker and would otherwise report ~double usage (#2290).
// Honor ECC's own knob and Claude Code's native CLAUDE_CODE_AUTO_COMPACT_WINDOW.
const env = (typeof process !== 'undefined' && process.env) || {};
const envWindow = Number.parseInt(env.ECC_CONTEXT_WINDOW_TOKENS || env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '', 10);
if (Number.isInteger(envWindow) && envWindow > 0) {
return envWindow;
return { windowTokens: envWindow, inferred: false };
}
if (typeof model === 'string' && model.includes(LARGE_WINDOW_MODEL_MARKER)) {
return LARGE_CONTEXT_WINDOW_TOKENS;
return { windowTokens: LARGE_CONTEXT_WINDOW_TOKENS, inferred: false };
}
// Large-window model families without a [1m] marker fall through the checks
@@ -183,15 +188,37 @@ function resolveContextWindowTokens(tokens, model) {
if (typeof model === 'string') {
const known = KNOWN_MODEL_WINDOW_TOKENS.find(([familyId]) => isKnownModelFamilyMatch(model, familyId));
if (known) {
return known[1];
return { windowTokens: known[1], inferred: false };
}
}
if (Number.isFinite(tokens) && tokens > STANDARD_CONTEXT_WINDOW_TOKENS) {
return LARGE_CONTEXT_WINDOW_TOKENS;
return { windowTokens: LARGE_CONTEXT_WINDOW_TOKENS, inferred: false };
}
return STANDARD_CONTEXT_WINDOW_TOKENS;
return { windowTokens: STANDARD_CONTEXT_WINDOW_TOKENS, inferred: true };
}
/**
* Detect the context window size for a turn.
* 1M when the model id carries the `[1m]` marker, matches a known large-window
* model family, or when the observed token count already exceeds the standard
* 200k window (covers logs that drop the suffix); otherwise the standard 200k
* window.
*/
function resolveContextWindowTokens(tokens, model) {
return resolveContextWindow(tokens, model).windowTokens;
}
/**
* True when the resolved window is the assumed 200k default rather than a
* detected size. Opt-in large-window models that ship no `[1m]` marker in the
* transcript (e.g. a 1M-context Opus tier, where the base tier is 200k and the
* two are indistinguishable by model id) land here, so a percentage computed
* against 200k can be wildly wrong while usage sits below that mark.
*/
function isContextWindowInferred(tokens, model) {
return resolveContextWindow(tokens, model).inferred;
}
/**
@@ -254,7 +281,9 @@ module.exports = {
DEFAULT_CONTEXT_INTERVAL_TOKENS,
DEFAULT_TRANSCRIPT_TAIL_BYTES,
readLatestContextTokens,
resolveContextWindow,
resolveContextWindowTokens,
isContextWindowInferred,
resolveContextThreshold,
resolveContextInterval,
computeContextBucket,
+33 -1
View File
@@ -23,7 +23,8 @@ const {
resolveContextThreshold,
resolveContextInterval,
computeContextBucket,
formatWindowLabel
formatWindowLabel,
isContextWindowInferred
} = require('../../scripts/lib/transcript-context');
console.log('=== Testing transcript-context.js ===\n');
@@ -218,6 +219,37 @@ test('treats an empty model id as standard window', () => {
assert.strictEqual(resolveContextWindowTokens(100000, ''), STANDARD_CONTEXT_WINDOW_TOKENS);
});
// ── isContextWindowInferred ──
console.log('\nisContextWindowInferred:');
delete process.env.ECC_CONTEXT_WINDOW_TOKENS;
delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
test('flags the assumed 200k default as inferred', () => {
assert.strictEqual(isContextWindowInferred(187000, 'claude-opus-9'), true);
});
test('an env override is a detected window, not inferred', () => {
process.env.ECC_CONTEXT_WINDOW_TOKENS = '1000000';
try {
assert.strictEqual(isContextWindowInferred(187000, 'claude-opus-9'), false);
} finally {
delete process.env.ECC_CONTEXT_WINDOW_TOKENS;
}
});
test('a [1m] marker is a detected window, not inferred', () => {
assert.strictEqual(isContextWindowInferred(187000, 'claude-opus-4-5[1m]'), false);
});
test('a known large-window family is a detected window, not inferred', () => {
assert.strictEqual(isContextWindowInferred(187000, 'claude-fable-5'), false);
});
test('tokens above the standard window make the size detected, not inferred', () => {
assert.strictEqual(isContextWindowInferred(220000, 'claude-opus-9'), false);
});
// ── resolveContextThreshold ──
console.log('\nresolveContextThreshold:');