Skip to content
Open
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
50 changes: 41 additions & 9 deletions docs/tui-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,21 +119,53 @@ about 70% and 95% of the context window depending on the model. Large tool
results and the serialized request size can also start compaction on their own;
tool-result reduction is configured separately under `toolResultCompaction`.

To compact earlier, for example to keep requests smaller, set an upper bound in
the data directory's `config.yaml`:
To compact earlier, for example to keep requests smaller, run `/autocompact` in a
Session to set the trigger for the current model:

```text
/autocompact # show the current model's trigger, its share of the window, and its source
/autocompact 300000 # a token count; 500k and 1.5M also work, and a bare 100-1000 means thousands (200 = 200K)
/autocompact auto # use the built-in trigger for the current model again
```

The setting is saved under `modelSettings` in the data directory's `config.yaml`,
in the same shape as Claude Code's `modelSettings.<model>.autoCompactWindow`. The
key is the Session's selected `provider/model`, such as `minimax/MiniMax-M3` or
`custom_provider:<name>/<model>`; `/autocompact` prints it:

```yaml
modelSettings:
"minimax/MiniMax-M3":
autoCompactWindow: 300000 # an integer token count above 1, or "auto"
```

A global upper bound for every model is also available:

```yaml
contextManagement:
compactionTriggerAt: 0.8 # ratio of the context window, or e.g. 300000 tokens
```

A value in `(0, 1]` is a ratio of the current model's context window; an integer
above 1 is an absolute token count. The effective trigger is the lower of this
value and the built-in trigger, so the setting can start compaction earlier but
never later, and has no lower bound: a value below what remains after compaction
compacts before every request. Other values, such as `1.5`, are ignored with a
warning in the runtime log. Automatic compaction cannot be disabled; `/compact`
remains available at any time. Apply changes by restarting MCode.
For `compactionTriggerAt`, a value in `(0, 1]` is a ratio of the current model's
context window and an integer above 1 is an absolute token count.

Precedence is the current model's `autoCompactWindow`, then the global
`compactionTriggerAt`, then the built-in trigger. `autoCompactWindow: auto` uses
the built-in trigger for that model and ignores the global value. The effective
trigger is the lower of the selected value and the built-in trigger, so a setting
can start compaction earlier but never later. A value above the built-in trigger
is saved as given, the built-in trigger still applies, and `/autocompact` says so.
There is no lower bound and, unlike Claude Code, no 100K-1M clamp: a value below
what remains after compaction compacts before every request. Invalid values, such
as `autoCompactWindow: 0` or `compactionTriggerAt: 1.5`, are ignored with a
warning in the runtime log.

The Auto-compact line in `/context` shows the current trigger, its share of the
window, and its source. A value saved with `/autocompact` applies from the next
model request. The save reserializes `config.yaml`, so comment formatting is not
retained. After editing `config.yaml` by hand, run `/reload` or restart MCode.
ACP clients can use `/autocompact` too. Automatic compaction cannot be disabled;
`/compact` remains available at any time.

## Local Bash execution

Expand Down
149 changes: 149 additions & 0 deletions packages/config/src/auto-compact-window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Per-model automatic compaction trigger, stored like Claude Code's
* `modelSettings.<model>.autoCompactWindow`:
*
* ```yaml
* modelSettings:
* "minimax/MiniMax-M3":
* autoCompactWindow: 400000 # token count, or "auto" for the built-in trigger
* ```
*
* The key is the source-qualified `provider/model` id. Model ids may contain
* dots, so the key is never addressed through a dotted config path.
*/

export type AutoCompactWindowSetting = number | 'auto';

export interface ModelSettingsEntry {
/** Positive integer token count above 1, or `auto`; other numbers are ignored at runtime. */
readonly autoCompactWindow?: AutoCompactWindowSetting;
}

export type ModelSettingsConfig = Readonly<Record<string, ModelSettingsEntry>>;

export type AutoCompactWindowInput =
| { readonly kind: 'auto' }
| { readonly kind: 'tokens'; readonly tokens: number }
| { readonly kind: 'invalid' };

/** Which setting decides a model's automatic compaction trigger. */
export type CompactionTriggerSetting =
| { readonly source: 'model'; readonly value: number }
| { readonly source: 'model-auto' }
| { readonly source: 'global'; readonly value: number }
| { readonly source: 'built-in' };

interface CompactionTriggerConfig {
readonly modelSettings?: ModelSettingsConfig;
readonly contextManagement?: { readonly compactionTriggerAt?: number };
}

const INPUT_PATTERN = /^(\d+)(?:\.(\d+))?([km])?$/u;
const SUFFIX_EXPONENT = { k: 3, m: 6 } as const;
const THOUSANDS_SHORTHAND = { min: 100, max: 1_000 } as const;

/**
* A token count above 1. `contextManagement.compactionTriggerAt` reads 1 as the
* whole window, so a per-model value of 1 would be ambiguous and is rejected.
*/
export function isValidAutoCompactWindowTokens(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 1;
}

/**
* Parses `/autocompact` input with Claude Code's grammar: a token count, a
* `k`/`M` suffix (decimals allowed when the result is whole), a bare 100–1000
* meaning thousands, or `auto`. Unlike Claude Code there is no lower bound or
* 1M clamp; the built-in trigger still caps the effective value.
*/
export function parseAutoCompactWindowInput(text: string): AutoCompactWindowInput {
const normalized = text.trim().toLowerCase();
if (normalized === 'auto') return { kind: 'auto' };
const match = INPUT_PATTERN.exec(normalized);
if (!match) return { kind: 'invalid' };
const [, whole = '', fraction, suffix] = match;
let tokens: number | undefined;
if (suffix === 'k' || suffix === 'm') {
tokens = scaleDecimal(whole, fraction ?? '', SUFFIX_EXPONENT[suffix]);
} else if (fraction === undefined) {
const value = Number(whole);
tokens =
value >= THOUSANDS_SHORTHAND.min && value <= THOUSANDS_SHORTHAND.max ? value * 1_000 : value;
}
return isValidAutoCompactWindowTokens(tokens) ? { kind: 'tokens', tokens } : { kind: 'invalid' };
}

/** Exact decimal scaling; undefined when the result is not a whole number. */
function scaleDecimal(whole: string, fraction: string, exponent: number): number | undefined {
// A loop instead of /0+$/, which backtracks quadratically on long zero runs.
let end = fraction.length;
while (end > 0 && fraction.endsWith('0', end)) end -= 1;
const significant = fraction.slice(0, end);
if (significant.length > exponent) return undefined;
return Number(`${whole}${significant.padEnd(exponent, '0')}`);
}

/**
* Keeps numbers for runtime validation and only the exact string `auto`;
* entries without a recognized field are dropped.
*/
export function parseModelSettings(raw: unknown): ModelSettingsConfig | undefined {
if (!isPlainRecord(raw)) return undefined;
const entries = Object.entries(raw).flatMap(([key, entry]) => {
if (!isPlainRecord(entry)) return [];
const value = entry.autoCompactWindow;
return typeof value === 'number' || value === 'auto'
? [[key, { autoCompactWindow: value }] as const]
: [];
});
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}

/**
* Picks the trigger setting for one `provider/model` key: a valid per-model
* value (`auto` included) wins over `contextManagement.compactionTriggerAt`,
* which wins over the built-in trigger. The global value is passed through
* as-is; the compaction budget validates it.
*/
export function selectCompactionTriggerSetting(
config: CompactionTriggerConfig,
modelKey: string,
): CompactionTriggerSetting {
const value = readModelSetting(config.modelSettings, modelKey)?.autoCompactWindow;
if (value === 'auto') return { source: 'model-auto' };
if (isValidAutoCompactWindowTokens(value)) return { source: 'model', value };
const global = config.contextManagement?.compactionTriggerAt;
return typeof global === 'number' ? { source: 'global', value: global } : { source: 'built-in' };
}

/** The `compactionTriggerAt` budget input a setting stands for; undefined keeps the built-in. */
export function compactionTriggerAtForSetting(
setting: CompactionTriggerSetting,
): number | undefined {
return setting.source === 'model' || setting.source === 'global' ? setting.value : undefined;
}

/** Per-model numbers the runtime ignores, for a one-time configuration warning. */
export function findInvalidAutoCompactWindows(
modelSettings: ModelSettingsConfig | undefined,
): readonly { readonly modelKey: string; readonly value: unknown }[] {
return Object.entries(modelSettings ?? {}).flatMap(([modelKey, entry]) => {
const value = entry.autoCompactWindow;
return value === undefined || value === 'auto' || isValidAutoCompactWindowTokens(value)
? []
: [{ modelKey, value }];
});
}

function readModelSetting(
modelSettings: ModelSettingsConfig | undefined,
modelKey: string,
): ModelSettingsEntry | undefined {
return modelSettings && Object.hasOwn(modelSettings, modelKey)
? modelSettings[modelKey]
: undefined;
}

function isPlainRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
11 changes: 10 additions & 1 deletion packages/config/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
SANDBOX_CONFIG_DEFAULTS,
type SandboxConfig,
} from "./sandbox-config.js";
import { parseModelSettings, type ModelSettingsConfig } from "./auto-compact-window.js";

export type { TuiConfig, TuiCustomStatusLineConfig } from "./tui-config.js";

Expand Down Expand Up @@ -894,7 +895,9 @@ export interface ContextManagementConfig {
* an integer above 1 is an absolute token count. The effective trigger is
* the lower of this value and the built-in trigger, so it can only make
* compaction start earlier. The runtime ignores invalid numbers (such as
* `1.5`) with a warning. Unset keeps the built-in behavior.
* `1.5`) with a warning. Unset keeps the built-in behavior. A valid
* `modelSettings.<provider/model>.autoCompactWindow` (including `auto`)
* replaces this value for that model.
*/
compactionTriggerAt?: number;
}
Expand Down Expand Up @@ -1008,6 +1011,11 @@ export interface Config {
opencode: OpenCodeAdapterConfig;
/** Per-model context management overrides (e.g. disable SR for low-context models). */
contextManagement: ContextManagementConfig;
/**
* Per-model settings keyed by the source-qualified `provider/model` id, such
* as `autoCompactWindow`; see ./auto-compact-window.ts. Undefined when absent.
*/
modelSettings?: ModelSettingsConfig;
/** Local Runaway Guard policy; valid Apollo booleans override it on Desktop. */
runawayGuard: RunawayGuardSettings;
/** ToolResult archive/trim thresholds shared by TUI, CLI and managed Desktop. */
Expand Down Expand Up @@ -2060,6 +2068,7 @@ export function resolveConfigFromRaw(
telemetry: parseTelemetryConfig(raw.telemetry),
opencode: parseOpenCodeAdapterConfig(raw, DEFAULTS.opencode),
contextManagement: parseContextManagementConfig(raw),
modelSettings: parseModelSettings(raw.modelSettings),
runawayGuard: resolveRunawayGuardConfig(raw.runawayGuard),
toolResultCompaction: parseToolResultCompactionConfig(
raw.toolResultCompaction,
Expand Down
15 changes: 15 additions & 0 deletions packages/config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,24 @@ export {
compareAndSetLocalModelContext,
removeLocalProviderConfig,
replaceLocalManagedMinimaxProvider,
updateLocalAutoCompactWindow,
updateLocalByokConfig,
updateLocalModelSelection,
} from './local-model-provider-write.js';
export {
compactionTriggerAtForSetting,
findInvalidAutoCompactWindows,
isValidAutoCompactWindowTokens,
parseAutoCompactWindowInput,
selectCompactionTriggerSetting,
} from './auto-compact-window.js';
export type {
AutoCompactWindowInput,
AutoCompactWindowSetting,
CompactionTriggerSetting,
ModelSettingsConfig,
ModelSettingsEntry,
} from './auto-compact-window.js';
export type {
LocalByokConfigDraft,
ManagedMinimaxProviderSnapshot,
Expand Down
26 changes: 26 additions & 0 deletions packages/config/src/local-model-provider-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

import { getConfig, getConfigPath, resetConfig, type Config } from './config.js';
import { minimaxApiModels } from './byok-config.js';
import {
isValidAutoCompactWindowTokens,
type AutoCompactWindowSetting,
} from './auto-compact-window.js';
import { MANAGED_MINIMAX_PROVIDER_ID, MINIMAX_API_PROVIDER_ID } from './model-availability.js';

const LOCAL_CONFIG_FILE_MODE = 0o600;
Expand Down Expand Up @@ -161,6 +165,28 @@
}).then(({ config }) => ({ config }));
}

/**
* Persist `modelSettings[modelKey].autoCompactWindow` under the shared config
* lock. The key is set as one path segment, so dotted model ids stay intact.
*/
export async function updateLocalAutoCompactWindow(input: {
readonly modelKey: string;
readonly value: AutoCompactWindowSetting;
}): Promise<{ readonly config: Config }> {
const modelKey = input.modelKey.trim();
const slash = modelKey.indexOf('/');
if (slash <= 0 || slash === modelKey.length - 1) {
throw new LocalModelProviderConfigValidationError('Invalid model key');
}
if (input.value !== 'auto' && !isValidAutoCompactWindowTokens(input.value)) {
throw new LocalModelProviderConfigValidationError('Invalid autoCompactWindow');
}
return withLockedConfig(async (raw) => {
setNestedValue(raw, ['modelSettings', modelKey, 'autoCompactWindow'], input.value);
return { write: true, value: undefined };
}).then(({ config }) => ({ config }));
}

/** Compare, remote-test, and persist a managed MiniMax model Context under one config lock. */
export async function compareAndSetLocalModelContext(
input: {
Expand Down Expand Up @@ -313,11 +339,11 @@
if (isPlainRecord(current)) target = current;
else {
const child: Record<string, unknown> = {};
target[segment] = child;

Check warning

Code scanning / CodeQL

Prototype-polluting assignment Medium

This assignment may alter Object.prototype if a malicious '__proto__' string is injected from
library input
.
target = child;
}
}
target[key] = value;

Check warning

Code scanning / CodeQL

Prototype-polluting assignment Medium

This assignment may alter Object.prototype if a malicious '__proto__' string is injected from
library input
.
}

function assertSafeConfigRecord(record: Record<string, unknown>): void {
Expand Down
Loading
Loading