Skip to content
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ acp-kernel/
│ ├── keep-markers.ts # KEEP/REF marker preservation in summaries
│ ├── types.ts # All shared types
│ └── defaults.ts # defaultConfig, defaultNodes
├── tests/ # 184 tests across 18 files
├── tests/ # 295 tests
├── tsup.config.ts
├── tsconfig.json
└── package.json
Expand Down
156 changes: 98 additions & 58 deletions src/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { adjustBoundariesForReasoningPairs } from "./reasoning-pairs.js";
import {
computeProtectedRefs,
buildCompressibleRanges,
mergeRangesToThreshold,
} from "./recommend.js";
import {
runPipeline,
Expand Down Expand Up @@ -465,7 +466,10 @@ const recommendNode: PipelineNode = {
const nothingToCompress = contextRanges.compressible.length === 0;
const recommendation: Recommendation = {
contextRanges,
recommendedRanges: contextRanges.compressible,
recommendedRanges: mergeRangesToThreshold(
contextRanges.compressible,
ctx.config.compress.minCompressRange,
),
nothingToCompress,
};
return { ...io, effects: { ...io.effects, recommendation } };
Expand Down Expand Up @@ -893,20 +897,23 @@ function resolveAdaptiveGrowth(
);
}

/** Compressible amount for each tier — all tiers use the SAME definition
* ("how much can be compressed at this tier"), so they are handled by one
* unified loop. T1 = compressible raw-message tokens (excludes protected /
* already-covered); T2 = total summary tokens of all active tier-1 blocks;
* T3 = total summary tokens of all active tier-2 blocks. The "zero filter"
* for block tiers is simply that all blocks are compressible by default. */
/** Compressible amount for each tier. T1 = EFFECTIVE merged-range tokens —
* only ranges whose `tokens*4 >= minCompressRange` count (avoids inflation
* from fragmentation); T2 = total summary tokens of all active tier-1 blocks;
* T3 = total summary tokens of all active tier-2 blocks. */
function pendingByTier(
state: CompressionState,
recommendation: Recommendation | undefined,
countTokens: (t: string) => number,
minCompressRange: number,
): Record<number, { pending: number; targetBlocks: CompressionBlock[] }> {
const out: Record<number, { pending: number; targetBlocks: CompressionBlock[] }> = {};
const compressible = recommendation?.contextRanges.compressible ?? [];
out[1] = { pending: compressible.reduce((s, r) => s + r.tokens, 0), targetBlocks: [] };
const merged = recommendation?.recommendedRanges ?? [];
const effective =
minCompressRange > 0
? merged.filter((r) => r.tokens * 4 >= minCompressRange)
: merged;
out[1] = { pending: effective.reduce((s, r) => s + r.tokens, 0), targetBlocks: [] };
const active = activeBlocks(state);
const t1 = active.filter((b) => b.tier === 1);
const t2 = active.filter((b) => b.tier === 2);
Expand All @@ -924,6 +931,9 @@ function decideNudge(input: NudgeInput): NudgeDecision {

const overLimit = usage >= config.nudge.maxContextLimitPct;
const emergencyOverride = usage >= config.nudge.emergencyThresholdPct;
// High-pressure band: over maxContextLimitPct (subsumes the emergency
// threshold). Bypasses growth gate + cadence; gated on effective pending.
const pressure = overLimit || emergencyOverride;

const baseline = state.nudge.lastPerMessageNudgeTokens;
const hadPendingNudge = state.nudge.lastNudgeShownTokens > 0;
Expand All @@ -948,66 +958,96 @@ function decideNudge(input: NudgeInput): NudgeDecision {
const growthSinceReference = tokenCount - growthReference;

const rec = recommendation;
const tiers = pendingByTier(state, rec, countTokens);
const tiers = pendingByTier(
state,
rec,
countTokens,
config.compress.minCompressRange,
);

// Unified injection arbitration, priority T1 > T2 > T3 (ascending). Each
// tier has its OWN cadence (lastShownByTier) so firing one doesn't block
// another across turns; within a turn we inject at most the FIRST eligible
// tier and stop, which raises the shared baseline (lastNudgeShownTokens) so
// lower-priority tiers see a higher reference and naturally defer.
// Tier arbitration. Emergency (usage >= emergencyThresholdPct) ignores tier
// priority and picks the tier with the MAX pending. Non-emergency defaults to
// T1; T2 and T3 override when each crossed the shared 1.5x threshold AND
// exceeds the effective pending of every lower tier (T2 > T1 effective;
// T3 > T2 and > T1 effective).
const tier2Threshold = Math.round(
nudgeGrowthTokens * (config.nudge.tier2GrowthMultiplier ?? 1.5),
);
let injectedTier: CompressionTier | null = null;
let injectedReason = "";
// growth acts as the "has accumulated since baseline" signal; pending is the
// "there is enough compressible content" signal. Both must hold for a
// non-emergency injection. This keeps the first turn (growth 0) from firing
// immediately even if a lot is compressible — it just establishes baseline.
const growthReady = growthSinceReference >= growthFloor;
if (!overLimit && growthReady) {
for (const tier of [1, 2, 3] as const) {
if (!config.tiers.enabled && tier > 1) break;
const info = tiers[tier];
if (!info || info.pending < nudgeGrowthTokens) continue;
const lastShown = state.nudge.lastShownByTier[tier] ?? 0;
// Per-tier cadence: even with growth, a tier that fired recently waits
// for its own cadence window. First time (lastShown===0) is always met.
const cadenceMet = lastShown === 0 || tokenCount - lastShown >= growthFloor;
if (!cadenceMet) continue;
injectedTier = tier;
injectedReason = tier === 1
? `T1 compressible ${info.pending} >= ${nudgeGrowthTokens}, growth ${growthSinceReference}, usage ${Math.round(usage * 100)}%`
: `T${tier} distill ready: ${info.targetBlocks.length} tier-${tier - 1} blocks (${info.pending} tokens) >= ${nudgeGrowthTokens}, usage ${Math.round(usage * 100)}%`;
break;
const t1Eff = tiers[1]?.pending ?? 0;
const t2Pen = tiers[2]?.pending ?? 0;
const t3Pen = tiers[3]?.pending ?? 0;

if (pressure) {
// High pressure: pick the tier with the MAX pending so pressure can route
// to distillation when that reclaims the most tokens. Gated on effective
// pending (tokens*4 >= minCompressRange for T1) so we never offer ranges
// the kernel would atomically reject. emergency vs over-limit only
// changes the reason label/voice; truncate.threshold remains the
// independent last resort when there is genuinely nothing to compress.
const candidates: CompressionTier[] = [1];
if (config.tiers.enabled) {
candidates.push(2, 3);
}
let best: CompressionTier | null = null;
let bestPending = 0;
for (const t of candidates) {
const p = tiers[t]?.pending ?? 0;
if (p > bestPending) {
bestPending = p;
best = t;
}
}
} else if (overLimit) {
// Over maxContextLimitPct: bypass growth gate + cadence, accept any tier
// with pending >= minCompressRange. emergencyThresholdPct and
// truncate.threshold are independent knobs — the truncate node fires on
// truncate.threshold regardless of emergencyThresholdPct.
for (const tier of [1, 2, 3] as const) {
if (!config.tiers.enabled && tier > 1) break;
const info = tiers[tier];
if (!info || info.pending < config.compress.minCompressRange) continue;
injectedTier = tier;
injectedReason = emergencyOverride
? `EMERGENCY: usage ${Math.round(usage * 100)}% >= ${Math.round(config.nudge.emergencyThresholdPct * 100)}%, T${tier} pending ${info.pending}`
: `OVER-LIMIT: usage ${Math.round(usage * 100)}% >= ${Math.round(config.nudge.maxContextLimitPct * 100)}%, T${tier} pending ${info.pending}`;
break;
if (best !== null && bestPending > 0) {
injectedTier = best;
const label = emergencyOverride ? "EMERGENCY" : "OVER-LIMIT";
injectedReason =
best === 1
? `${label} T1: max effective pending ${bestPending}, usage ${Math.round(usage * 100)}%`
: `${label} T${best} distill: max pending ${bestPending} (T1 effective ${t1Eff}, T2 ${t2Pen}, T3 ${t3Pen}), usage ${Math.round(usage * 100)}%`;
}
} else if (growthReady) {
if (t1Eff >= nudgeGrowthTokens) {
injectedTier = 1;
injectedReason = `T1 effective ${t1Eff} >= ${nudgeGrowthTokens}, growth ${growthSinceReference}, usage ${Math.round(usage * 100)}%`;
} else if (
config.tiers.enabled &&
t2Pen >= tier2Threshold &&
t2Pen > t1Eff
) {
const lastShown = state.nudge.lastShownByTier[2] ?? 0;
const cadenceMet =
lastShown === 0 || tokenCount - lastShown >= growthFloor;
if (cadenceMet) {
injectedTier = 2;
injectedReason = `T2 distill ready: ${tiers[2]!.targetBlocks.length} tier-1 blocks (${t2Pen} tokens) >= ${tier2Threshold} (1.5x) and > T1 effective ${t1Eff}, usage ${Math.round(usage * 100)}%`;
}
} else if (
config.tiers.enabled &&
t3Pen >= tier2Threshold &&
t3Pen > t2Pen &&
t3Pen > t1Eff
) {
const lastShown = state.nudge.lastShownByTier[3] ?? 0;
const cadenceMet =
lastShown === 0 || tokenCount - lastShown >= growthFloor;
if (cadenceMet) {
injectedTier = 3;
injectedReason = `T3 condense ready: ${tiers[3]!.targetBlocks.length} tier-2 blocks (${t3Pen} tokens) >= ${tier2Threshold} (1.5x) and > T2 ${t2Pen} and > T1 effective ${t1Eff}, usage ${Math.round(usage * 100)}%`;
}
}
}

const shouldInject = injectedTier !== null || (overLimit && (rec?.recommendedRanges?.length ?? 0) > 0);
const shouldInject = injectedTier !== null;

let reason: string;
if (emergencyOverride && injectedTier !== null) {
reason = injectedReason;
} else if (emergencyOverride) {
reason = `EMERGENCY: usage ${Math.round(usage * 100)}% >= ${Math.round(config.nudge.emergencyThresholdPct * 100)}% (no compressible content)`;
} else if (overLimit && injectedTier !== null) {
reason = injectedReason;
} else if (overLimit) {
reason = `OVER-LIMIT: usage ${Math.round(usage * 100)}% >= ${Math.round(config.nudge.maxContextLimitPct * 100)}% (no compressible content)`;
} else if (injectedTier !== null) {
if (injectedTier !== null) {
reason = injectedReason;
} else if (pressure) {
const label = emergencyOverride ? "EMERGENCY" : "OVER-LIMIT";
reason = `${label}: usage ${Math.round(usage * 100)}% but no tier has effective compressible content (T1 effective ${t1Eff}, T2 ${t2Pen}, T3 ${t3Pen}) — nudge suppressed to avoid offering ranges below minCompressRange`;
} else {
const tiersList = [1, 2, 3] as const;
const eligible = tiersList.filter((t) => config.tiers.enabled || t === 1);
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function defaultConfig(
minGrowthFloor: 20000,
minGrowthRatio: 0.45,
emergencyThresholdPct: 0.95,
tier2GrowthMultiplier: 1.5,
},
promotionThreshold: 5,
truncate: { threshold: 0.95 },
Expand Down
11 changes: 7 additions & 4 deletions src/nudge-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,21 +123,26 @@ export function formatRanges(compressible: CompressibleRange[], protectedRanges:
export function renderNudgeText(decision: NudgeDecision, prompts: Prompts = defaultPrompts): RenderedNudge {
const breakdownStr = formatBreakdown(decision.contextBreakdown);
const rangesStr = formatRanges(decision.compressibleRanges, decision.protectedRanges ?? []);
const isEmergency = !!decision.breakdown?.emergencyOverride || !!decision.breakdown?.overLimit;

if (decision.tier !== null && decision.tier >= 2) {
const isT2 = decision.tier === 2;
const targets = decision.tierTargetBlocks ?? [];
const blockList = formatTierTargetBlocks(targets);
const startId = targets[0]?.blockId ?? "b1";
const endId = targets[targets.length - 1]?.blockId ?? "b5";
const voice: NudgeVoice = isEmergency ? "emergency" : "gentle";
const triggerLine = isEmergency
? `[EMERGENCY — TIER ${decision.tier} ${isT2 ? "DISTILLATION" : "CONDENSATION"}] Context limit reached — distill NOW into a denser summary to reclaim tokens.`
: `[TIER ${decision.tier} ${isT2 ? "DISTILLATION" : "CONDENSATION"} TRIGGER]`;
return {
voice: "gentle",
voice,
text: [
efficiencyNote(prompts),
"",
breakdownStr,
"",
`[TIER ${decision.tier} ${isT2 ? "DISTILLATION" : "CONDENSATION"} TRIGGER]`,
triggerLine,
isT2
? `Your tier-1 compression summaries have accumulated. Distill them into a single denser tier-2 summary. Use block IDs as boundaries (startId and endId as bN). Any raw (uncompressed) messages sitting between the boundary blocks are absorbed into the tier-2 block as well — apply HOW TO COMPRESS to those raw messages and the TIER 2 distillation rules to the existing summaries, so the whole span is covered and nothing is lost.`
: `Your tier-2 compression summaries have accumulated. Condense them further into a tier-3 ultra-condensed summary. Use block IDs as boundaries (startId and endId as bN). Any raw (uncompressed) messages sitting between the boundary blocks are absorbed into the tier-3 block as well — apply HOW TO COMPRESS to those raw messages and the TIER 3 condensation rules to the existing summaries, so the whole span is covered and nothing is lost.`,
Expand All @@ -151,8 +156,6 @@ export function renderNudgeText(decision: NudgeDecision, prompts: Prompts = defa
};
}

const isEmergency = !!decision.breakdown?.emergencyOverride || !!decision.breakdown?.overLimit;

if (isEmergency) {
return {
voice: "emergency",
Expand Down
43 changes: 43 additions & 0 deletions src/recommend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,46 @@ export function buildCompressibleRanges(
protected: protectedRanges,
};
}

function mergeBatch(batch: CompressibleRange[]): CompressibleRange {
const first = batch[0]!;
const last = batch[batch.length - 1]!;
const count = batch.reduce((s, r) => s + r.count, 0);
const tokens = batch.reduce((s, r) => s + r.tokens, 0);
const toolPct = Math.round(
batch.reduce((s, r) => s + r.toolPct * r.count, 0) / count,
);
const merged: CompressibleRange = {
startRef: first.startRef,
endRef: last.endRef,
count,
tokens,
toolPct,
textPct: 100 - toolPct,
};
if (batch.some((r) => r.dangerous === true)) {
merged.dangerous = true;
}
return merged;
}

export function mergeRangesToThreshold(
ranges: CompressibleRange[],
minChars: number,
): CompressibleRange[] {
if (minChars <= 0 || ranges.length === 0) return ranges;
const result: CompressibleRange[] = [];
let batch: CompressibleRange[] = [];
for (const r of ranges) {
batch.push(r);
const batchTokens = batch.reduce((s, x) => s + x.tokens, 0);
if (batchTokens * 4 >= minChars) {
result.push(mergeBatch(batch));
batch = [];
}
}
if (batch.length > 0) {
result.push(mergeBatch(batch));
}
return result;
}
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ export interface NudgeConfig {
minGrowthRatio: number;
/** Emergency override: always nudge when usage ≥ this fraction. Default 0.98 (98%). */
emergencyThresholdPct: number;
/** Growth multiplier for the tier-2 trigger: T2 distillation fires when T2
* pending ≥ nudgeGrowthTokens × this multiplier AND T2 > T1 effective.
* Default 1.5. */
tier2GrowthMultiplier: number;
}

export interface TruncateConfig {
Expand Down
24 changes: 24 additions & 0 deletions tests/nudge-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,30 @@ test("tier-3 condensation: guidance warns raw messages in span are absorbed", ()
assert.ok(result.text.includes("HOW TO COMPRESS"), "should direct raw messages to HOW TO COMPRESS rules");
});

test("emergency + tier 2: emergency voice with distillation guidance", () => {
const result = renderNudgeText(
makeDecision({ tier: 2, breakdown: { emergencyOverride: 1 } }),
);
assert.equal(result.voice, "emergency");
assert.ok(
result.text.toLowerCase().includes("distill"),
"should still carry distillation guidance",
);
assert.ok(result.text.includes("TIER 2"), "should still name the tier");
});

test("emergency + tier 3: emergency voice with condensation guidance", () => {
const result = renderNudgeText(
makeDecision({ tier: 3, breakdown: { emergencyOverride: 1 } }),
);
assert.equal(result.voice, "emergency");
assert.ok(
result.text.toLowerCase().includes("condense"),
"should still carry condensation guidance",
);
assert.ok(result.text.includes("TIER 3"), "should still name the tier");
});

test("both modes include compressible ranges", () => {
const gentle = renderNudgeText(makeDecision({ contextUsage: 0.5 }));
const emergency = renderNudgeText(
Expand Down
Loading
Loading