Skip to content

Commit 77922c2

Browse files
committed
feat(compress): add max/min nudge limits
1 parent f5590d8 commit 77922c2

5 files changed

Lines changed: 155 additions & 65 deletions

File tree

README.md

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,16 +124,27 @@ DCP uses its own config file:
124124
"permission": "allow",
125125
// Show compression content in a chat notification
126126
"showCompression": false,
127-
// Token limit at which the model compresses session context
128-
// to keep the model in the "smart zone" (not a hard limit)
129-
// Accepts: number or "X%" (percentage of model's context window)
130-
"contextLimit": 100000,
131-
// Optional per-model overrides by exact providerID/modelID
132-
// Accepts: number or "X%"
127+
// Soft upper threshold: above this, DCP keeps injecting strong
128+
// compression nudges (based on nudgeFrequency), so compression is
129+
// much more likely. Accepts: number or "X%" of model context window.
130+
"maxContextLimit": 100000,
131+
// Soft lower threshold for reminder nudges: below this, turn/iteration
132+
// reminders are off (compression less likely). At/above this, reminders
133+
// are on. Accepts: number or "X%" of model context window.
134+
"minContextLimit": 30000,
135+
// Optional per-model override for maxContextLimit by providerID/modelID.
136+
// If present, this wins over the global maxContextLimit.
137+
// Accepts: number or "X%".
133138
// Example:
134-
// "modelLimits": {
135-
// "openai/gpt-5": 120000,
136-
// "anthropic/claude-3-7-sonnet": "80%"
139+
// "modelMaxLimits": {
140+
// "openai/gpt-5.3-codex": 120000,
141+
// "anthropic/claude-sonnet-4.6": "80%"
142+
// },
143+
// Optional per-model override for minContextLimit.
144+
// If present, this wins over the global minContextLimit.
145+
// "modelMinLimits": {
146+
// "openai/gpt-5.3-codex": 30000,
147+
// "anthropic/claude-sonnet-4.6": "25%"
137148
// },
138149
// How often the context-limit nudge fires (1 = every fetch, 5 = every 5th)
139150
"nudgeFrequency": 5,

dcp.schema.json

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,8 @@
133133
"default": false,
134134
"description": "Show compression summaries in notifications"
135135
},
136-
"contextLimit": {
137-
"description": "When session tokens exceed this limit, a compress nudge is injected (\"X%\" uses percentage of the model's context window)",
136+
"maxContextLimit": {
137+
"description": "Soft upper threshold. Above this, DCP keeps sending strong compression nudges (based on nudgeFrequency), so the model is pushed to compress. Accepts number or \"X%\" of the model context window.",
138138
"default": 100000,
139139
"oneOf": [
140140
{
@@ -146,8 +146,36 @@
146146
}
147147
]
148148
},
149-
"modelLimits": {
150-
"description": "Model-specific context limits by exact provider/model key. Examples: \"openai/gpt-5\", \"anthropic/claude-3-7-sonnet\", \"ollama/llama3.1\"",
149+
"minContextLimit": {
150+
"description": "Soft lower threshold for reminder nudges. Below this, turn/iteration reminders are off (compression is less likely). At or above this, reminders are on. Accepts number or \"X%\" of the model context window.",
151+
"default": 30000,
152+
"oneOf": [
153+
{
154+
"type": "number"
155+
},
156+
{
157+
"type": "string",
158+
"pattern": "^\\d+(?:\\.\\d+)?%$"
159+
}
160+
]
161+
},
162+
"modelMaxLimits": {
163+
"description": "Per-model override for maxContextLimit by exact provider/model key. If set, this takes priority over the global maxContextLimit.",
164+
"type": "object",
165+
"additionalProperties": {
166+
"oneOf": [
167+
{
168+
"type": "number"
169+
},
170+
{
171+
"type": "string",
172+
"pattern": "^\\d+(?:\\.\\d+)?%$"
173+
}
174+
]
175+
}
176+
},
177+
"modelMinLimits": {
178+
"description": "Per-model override for minContextLimit by exact provider/model key. If set, this takes priority over the global minContextLimit.",
151179
"type": "object",
152180
"additionalProperties": {
153181
"oneOf": [
@@ -165,7 +193,7 @@
165193
"type": "number",
166194
"default": 5,
167195
"minimum": 1,
168-
"description": "How often the context-limit nudge fires when above contextLimit (1 = every fetch, 5 = every 5th fetch)"
196+
"description": "How often the context-limit nudge fires when above maxContextLimit (1 = every fetch, 5 = every 5th fetch)"
169197
},
170198
"iterationNudgeThreshold": {
171199
"type": "number",

lib/config.ts

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ export interface Deduplication {
1414
export interface CompressTool {
1515
permission: Permission
1616
showCompression: boolean
17-
contextLimit: number | `${number}%`
18-
modelLimits?: Record<string, number | `${number}%`>
17+
maxContextLimit: number | `${number}%`
18+
minContextLimit: number | `${number}%`
19+
modelMaxLimits?: Record<string, number | `${number}%`>
20+
modelMinLimits?: Record<string, number | `${number}%`>
1921
nudgeFrequency: number
2022
iterationNudgeThreshold: number
2123
nudgeForce: "strong" | "soft"
@@ -105,8 +107,10 @@ export const VALID_CONFIG_KEYS = new Set([
105107
"compress",
106108
"compress.permission",
107109
"compress.showCompression",
108-
"compress.contextLimit",
109-
"compress.modelLimits",
110+
"compress.maxContextLimit",
111+
"compress.minContextLimit",
112+
"compress.modelMaxLimits",
113+
"compress.modelMinLimits",
110114
"compress.nudgeFrequency",
111115
"compress.iterationNudgeThreshold",
112116
"compress.nudgeForce",
@@ -129,8 +133,8 @@ function getConfigKeyPaths(obj: Record<string, any>, prefix = ""): string[] {
129133
const fullKey = prefix ? `${prefix}.${key}` : key
130134
keys.push(fullKey)
131135

132-
// modelLimits is a dynamic map keyed by providerID/modelID; do not recurse into arbitrary IDs.
133-
if (fullKey === "compress.modelLimits") {
136+
// model*Limits are dynamic maps keyed by providerID/modelID; do not recurse into arbitrary IDs.
137+
if (fullKey === "compress.modelMaxLimits" || fullKey === "compress.modelMinLimits") {
134138
continue
135139
}
136140

@@ -384,47 +388,65 @@ export function validateConfigTypes(config: Record<string, any>): ValidationErro
384388
})
385389
}
386390

387-
if (compress.contextLimit !== undefined) {
388-
const isValidNumber = typeof compress.contextLimit === "number"
389-
const isPercentString =
390-
typeof compress.contextLimit === "string" && compress.contextLimit.endsWith("%")
391+
const validateLimitValue = (
392+
key: string,
393+
value: unknown,
394+
actualValue: unknown = value,
395+
): void => {
396+
const isValidNumber = typeof value === "number"
397+
const isPercentString = typeof value === "string" && value.endsWith("%")
391398

392399
if (!isValidNumber && !isPercentString) {
393400
errors.push({
394-
key: "compress.contextLimit",
401+
key,
395402
expected: 'number | "${number}%"',
396-
actual: JSON.stringify(compress.contextLimit),
403+
actual: JSON.stringify(actualValue),
397404
})
398405
}
399406
}
400407

401-
if (compress.modelLimits !== undefined) {
402-
if (
403-
typeof compress.modelLimits !== "object" ||
404-
compress.modelLimits === null ||
405-
Array.isArray(compress.modelLimits)
406-
) {
408+
const validateModelLimits = (
409+
key: "compress.modelMaxLimits" | "compress.modelMinLimits",
410+
limits: unknown,
411+
): void => {
412+
if (limits === undefined) {
413+
return
414+
}
415+
416+
if (typeof limits !== "object" || limits === null || Array.isArray(limits)) {
407417
errors.push({
408-
key: "compress.modelLimits",
418+
key,
409419
expected: "Record<string, number | ${number}%>",
410-
actual: typeof compress.modelLimits,
420+
actual: typeof limits,
411421
})
412-
} else {
413-
for (const [providerModelKey, limit] of Object.entries(compress.modelLimits)) {
414-
const isValidNumber = typeof limit === "number"
415-
const isPercentString =
416-
typeof limit === "string" && /^\d+(?:\.\d+)?%$/.test(limit)
417-
if (!isValidNumber && !isPercentString) {
418-
errors.push({
419-
key: `compress.modelLimits.${providerModelKey}`,
420-
expected: 'number | "${number}%"',
421-
actual: JSON.stringify(limit),
422-
})
423-
}
422+
return
423+
}
424+
425+
for (const [providerModelKey, limit] of Object.entries(limits)) {
426+
const isValidNumber = typeof limit === "number"
427+
const isPercentString =
428+
typeof limit === "string" && /^\d+(?:\.\d+)?%$/.test(limit)
429+
if (!isValidNumber && !isPercentString) {
430+
errors.push({
431+
key: `${key}.${providerModelKey}`,
432+
expected: 'number | "${number}%"',
433+
actual: JSON.stringify(limit),
434+
})
424435
}
425436
}
426437
}
427438

439+
if (compress.maxContextLimit !== undefined) {
440+
validateLimitValue("compress.maxContextLimit", compress.maxContextLimit)
441+
}
442+
443+
if (compress.minContextLimit !== undefined) {
444+
validateLimitValue("compress.minContextLimit", compress.minContextLimit)
445+
}
446+
447+
validateModelLimits("compress.modelMaxLimits", compress.modelMaxLimits)
448+
validateModelLimits("compress.modelMinLimits", compress.modelMinLimits)
449+
428450
const validValues = ["ask", "allow", "deny"]
429451
if (compress.permission !== undefined && !validValues.includes(compress.permission)) {
430452
errors.push({
@@ -602,7 +624,8 @@ const defaultConfig: PluginConfig = {
602624
compress: {
603625
permission: "allow",
604626
showCompression: false,
605-
contextLimit: 100000,
627+
maxContextLimit: 100000,
628+
minContextLimit: 30000,
606629
nudgeFrequency: 5,
607630
iterationNudgeThreshold: 15,
608631
nudgeForce: "soft",
@@ -767,8 +790,10 @@ function mergeCompress(
767790
return {
768791
permission: override.permission ?? base.permission,
769792
showCompression: override.showCompression ?? base.showCompression,
770-
contextLimit: override.contextLimit ?? base.contextLimit,
771-
modelLimits: override.modelLimits ?? base.modelLimits,
793+
maxContextLimit: override.maxContextLimit ?? base.maxContextLimit,
794+
minContextLimit: override.minContextLimit ?? base.minContextLimit,
795+
modelMaxLimits: override.modelMaxLimits ?? base.modelMaxLimits,
796+
modelMinLimits: override.modelMinLimits ?? base.modelMinLimits,
772797
nudgeFrequency: override.nudgeFrequency ?? base.nudgeFrequency,
773798
iterationNudgeThreshold: override.iterationNudgeThreshold ?? base.iterationNudgeThreshold,
774799
nudgeForce: override.nudgeForce ?? base.nudgeForce,
@@ -829,7 +854,8 @@ function deepCloneConfig(config: PluginConfig): PluginConfig {
829854
protectedFilePatterns: [...config.protectedFilePatterns],
830855
compress: {
831856
...config.compress,
832-
modelLimits: { ...config.compress.modelLimits },
857+
modelMaxLimits: { ...config.compress.modelMaxLimits },
858+
modelMinLimits: { ...config.compress.modelMinLimits },
833859
protectedTools: [...config.compress.protectedTools],
834860
},
835861
strategies: {

lib/messages/inject/inject.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
getIterationNudgeThreshold,
2121
getNudgeFrequency,
2222
getModelInfo,
23-
isContextOverLimit,
23+
isContextOverLimits,
2424
messageHasCompress,
2525
} from "./utils"
2626

@@ -52,9 +52,26 @@ export const insertCompressNudges = (
5252
const { providerId, modelId } = getModelInfo(messages)
5353
let anchorsChanged = false
5454

55-
const contextOverLimit = isContextOverLimit(config, state, providerId, modelId, messages)
55+
const { overMaxLimit, overMinLimit } = isContextOverLimits(
56+
config,
57+
state,
58+
providerId,
59+
modelId,
60+
messages,
61+
)
62+
63+
if (!overMinLimit) {
64+
const hadTurnAnchors = state.nudges.turnNudgeAnchors.size > 0
65+
const hadIterationAnchors = state.nudges.iterationNudgeAnchors.size > 0
66+
67+
if (hadTurnAnchors || hadIterationAnchors) {
68+
state.nudges.turnNudgeAnchors.clear()
69+
state.nudges.iterationNudgeAnchors.clear()
70+
anchorsChanged = true
71+
}
72+
}
5673

57-
if (contextOverLimit) {
74+
if (overMaxLimit) {
5875
if (lastMessage) {
5976
const interval = getNudgeFrequency(config)
6077
const added = addAnchor(
@@ -68,7 +85,7 @@ export const insertCompressNudges = (
6885
anchorsChanged = true
6986
}
7087
}
71-
} else {
88+
} else if (overMinLimit) {
7289
const isLastMessageUser = lastMessage?.message.info.role === "user"
7390

7491
if (isLastMessageUser && lastAssistantMessage) {

lib/messages/inject/utils.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,12 @@ export function getModelInfo(messages: WithParts[]): LastUserModelContext {
8181
}
8282
}
8383

84-
function resolveContextLimit(
84+
function resolveContextTokenLimit(
8585
config: PluginConfig,
8686
state: SessionState,
8787
providerId: string | undefined,
8888
modelId: string | undefined,
89+
threshold: "max" | "min",
8990
): number | undefined {
9091
const parseLimitValue = (limit: number | `${number}%` | undefined): number | undefined => {
9192
if (limit === undefined) {
@@ -110,7 +111,8 @@ function resolveContextLimit(
110111
return Math.round((clampedPercent / 100) * state.modelContextLimit)
111112
}
112113

113-
const modelLimits = config.compress.modelLimits
114+
const modelLimits =
115+
threshold === "max" ? config.compress.modelMaxLimits : config.compress.modelMinLimits
114116
if (modelLimits && providerId !== undefined && modelId !== undefined) {
115117
const providerModelId = `${providerId}/${modelId}`
116118
const modelLimit = modelLimits[providerModelId]
@@ -119,23 +121,29 @@ function resolveContextLimit(
119121
}
120122
}
121123

122-
return parseLimitValue(config.compress.contextLimit)
124+
const globalLimit =
125+
threshold === "max" ? config.compress.maxContextLimit : config.compress.minContextLimit
126+
return parseLimitValue(globalLimit)
123127
}
124128

125-
export function isContextOverLimit(
129+
export function isContextOverLimits(
126130
config: PluginConfig,
127131
state: SessionState,
128132
providerId: string | undefined,
129133
modelId: string | undefined,
130134
messages: WithParts[],
131-
): boolean {
132-
const contextLimit = resolveContextLimit(config, state, providerId, modelId)
133-
if (contextLimit === undefined) {
134-
return false
135-
}
136-
135+
) {
136+
const maxContextLimit = resolveContextTokenLimit(config, state, providerId, modelId, "max")
137+
const minContextLimit = resolveContextTokenLimit(config, state, providerId, modelId, "min")
137138
const currentTokens = getCurrentTokenUsage(messages)
138-
return currentTokens > contextLimit
139+
140+
const overMaxLimit = maxContextLimit === undefined ? false : currentTokens > maxContextLimit
141+
const overMinLimit = minContextLimit === undefined ? true : currentTokens >= minContextLimit
142+
143+
return {
144+
overMaxLimit,
145+
overMinLimit,
146+
}
139147
}
140148

141149
export function addAnchor(

0 commit comments

Comments
 (0)