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
118 changes: 84 additions & 34 deletions src/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { adjustBoundariesForToolPairs } from "./tool-pairs.js";
import {
computeProtectedRefs,
buildCompressibleRanges,
mergeRangesToThreshold,
} from "./recommend.js";
import {
runPipeline,
Expand Down Expand Up @@ -403,7 +404,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 @@ -812,20 +816,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 Down Expand Up @@ -866,35 +873,78 @@ 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 (!emergencyOverride && 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 (emergencyOverride) {
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;
}
}
if (best !== null) {
injectedTier = best;
injectedReason =
best === 1
? `EMERGENCY T1: max effective pending ${bestPending}, usage ${Math.round(usage * 100)}%`
: `EMERGENCY 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)}%`;
}
}
}

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.80,
tier2GrowthMultiplier: 1.5,
},
promotionThreshold: 5,
truncate: { threshold: 1 },
Expand Down
14 changes: 9 additions & 5 deletions src/nudge-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,21 +118,27 @@ export function formatRanges(compressible: CompressibleRange[], protectedRanges:
export function renderNudgeText(decision: NudgeDecision): RenderedNudge {
const breakdownStr = formatBreakdown(decision.contextBreakdown);
const rangesStr = formatRanges(decision.compressibleRanges, decision.protectedRanges ?? []);
const isEmergency = !!decision.breakdown?.emergencyOverride;

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 header = isEmergency ? EMERGENCY_HEADER : EFFICIENCY_NOTE;
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: [
EFFICIENCY_NOTE,
header,
"",
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 @@ -146,8 +152,6 @@ export function renderNudgeText(decision: NudgeDecision): RenderedNudge {
};
}

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

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