diff --git a/.changeset/inline-once-overrides.md b/.changeset/inline-once-overrides.md new file mode 100644 index 000000000..f65519c0b --- /dev/null +++ b/.changeset/inline-once-overrides.md @@ -0,0 +1,18 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Added `?key=value` inline overrides to slash commands. An override is applied via the existing session-override plumbing and restored to its prior value when the command finishes, so users can test a setting for a single command without changing the session state. Closes #1151. + +**Working examples:** +- `/usage ?context-max=200k` - renders context usage against a 200k limit for this command only, then restores the prior limit. +- `/compact ?threshold=80` - gates the manual compaction on current usage: compacts normally at or above 80%, otherwise reports the usage and skips (mirroring the automatic path's gate). Composes with `?context-max`, which the gate reads as the limit. +- `/compact ?preview`, `/compact ?auto-on` - boolean flags forwarded as their `--flag` long forms. + +**Behavior:** +- `threshold` values outside 50–95 and unparseable values are ignored (fail open: the command runs un-gated). +- `auto-compact` is parsed and round-tripped through the session-override stores (apply then restore), but no built-in slash command currently reads it synchronously during dispatch — reserved plumbing for the automatic compaction path. +- Unknown `?foo=bar` keys are never consumed: the dispatcher forwards them to the command handler verbatim (so each command's own unknown-arg handling runs) and queues one warning naming the key, so a typo cannot silently no-op. +- Values containing additional `=` are preserved (`?config=key=value` → `'key=value'`). +- Override applies only to the current command; it does not affect global session state. +- Custom commands and MCP prompts bypass override parsing (they receive `?foo=1` literally). diff --git a/source/app/utils/app-util.spec.ts b/source/app/utils/app-util.spec.ts index 3fe95ddf5..5792ecfaf 100644 --- a/source/app/utils/app-util.spec.ts +++ b/source/app/utils/app-util.spec.ts @@ -9,13 +9,24 @@ import { parseCustomCommandArgs, } from './app-util.js'; import {SETTINGS_TAB_IDS} from '@/app/components/settings-constants'; +import {parseInput} from '@/command-parser'; import {commandRegistry} from '@/commands'; import {lazyCommands} from '@/commands/lazy-registry'; import BashProgress from '@/components/bash-progress'; import CommandProgress from '@/components/command-progress'; -import type {MessageSubmissionOptions} from '@/types/index'; import type {Session} from '@/session/session-manager'; import {sessionManager} from '@/session/session-manager'; +import type {Message, MessageSubmissionOptions} from '@/types/index'; +import { + autoCompactSessionOverrides, + resetAutoCompactSession, + setAutoCompactThreshold, +} from '@/utils/auto-compact'; +import { + applyOnceOverrides, + expandOverrideArgs, + parseInlineOverrides, +} from '@/utils/inline-overrides'; // Test command parsing edge cases // These tests document the expected behavior of parsing patterns @@ -1216,3 +1227,192 @@ test('progress spinner - only slow commands opt in', t => { const help = lazyCommands.find(c => c.name === 'help'); t.is(help?.progressLabel, undefined); }); + +// --- Inline `?key=value` overrides (issue #1151) --- + +test('inline overrides - /usage ?context-max=200k is parsed into a session-override', t => { + // ?context-max is a session-override key (handled by applyOnceOverrides), + // not a legacy --flag, so expandOverrideArgs leaves it alone and the + // dispatcher applies it through the existing session-override stores. + // /usage reads getSessionContextLimit() inside its awaited handler, so + // the override is observable for that command's run. + const trimmed = '/usage ?context-max=200k'.slice(1).trim().split(/\s+/); + const {args, overrides} = parseInlineOverrides(trimmed.slice(1)); + t.deepEqual(args, []); + t.deepEqual(overrides, [{key: 'context-max', value: '200k'}]); + t.deepEqual(expandOverrideArgs(overrides), []); +}); + +test('inline overrides - mixed positional args survive the split', t => { + const trimmed = '/compact --mechanical ?preview --llm' + .slice(1) + .trim() + .split(/\s+/); + const {args, overrides} = parseInlineOverrides(trimmed.slice(1)); + t.deepEqual(args, ['--mechanical', '--llm']); + t.deepEqual(overrides, [{key: 'preview', value: true}]); +}); + +test('inline overrides - applyOnceOverrides restore function is idempotent and safe', async t => { + const restore = await applyOnceOverrides([]); + t.notThrows(() => restore()); + t.notThrows(() => restore()); +}); + +function queuedText(node: React.ReactNode): string { + if (React.isValidElement(node)) { + const {message} = node.props as {message?: unknown}; + return typeof message === 'string' ? message : ''; + } + return typeof node === 'string' ? node : ''; +} + +test.serial('inline overrides - unknown ?keys warn and reach the handler unchanged', async t => { + // Regression: an unrecognised `?key=value` token must neither vanish + // silently nor pollute the override stores. The dispatcher forwards it + // verbatim (so `/context-max` reports its usual "invalid limit" error + // for the `?bogus=1` positional) and queues one warning naming the key. + const texts: string[] = []; + const options = createResumeTestOptions({ + onAddToChatQueue: node => { + texts.push(queuedText(node)); + }, + }); + + await handleMessageSubmission('/context-max ?bogus=1', options); + + t.true( + texts.some(text => text.includes('?bogus')), + `expected an unknown-override warning naming ?bogus, got: ${JSON.stringify(texts)}`, + ); + t.true( + texts.some(text => text.includes('Invalid context limit')), + `expected the handler's normal invalid-limit error, got: ${JSON.stringify(texts)}`, + ); +}); + +test.serial('inline overrides - dispatcher applies a ?context-max override and restores the prior value', async t => { + // Regression: a once-scoped override must write the new value into the + // session-override store for the duration of the command, and restore the + // **prior** value (not null) afterwards so a pre-existing session setting + // survives the override. /usage is exercised end-to-end via + // handleMessageSubmission because it is the one built-in that reads + // getSessionContextLimit() synchronously inside its handler. + const {getSessionContextLimit, setSessionContextLimit, resetSessionContextLimit} = + await import('@/models/index.js'); + resetSessionContextLimit(); + setSessionContextLimit(8192); + + let limitDuringCall: number | null | undefined; + const options = createResumeTestOptions({ + onAddToChatQueue: () => { + limitDuringCall = getSessionContextLimit(); + }, + }); + + await handleMessageSubmission('/usage ?context-max=200k', options); + + t.is( + limitDuringCall, + 200000, + 'applyOnceOverrides should have written 200000 before the usage handler ran', + ); + t.is( + getSessionContextLimit(), + 8192, + 'restoreOnce should have put the prior 8192 back after the command', + ); + + resetSessionContextLimit(); +}); + +test.serial('inline overrides - /compact ?threshold skips compaction below the threshold', async t => { + // End-to-end for the headline example: the once-threshold gates the + // manual compaction (mirroring the automatic path's gate). A tiny + // transcript against a huge once-limit sits near 0%, so the command + // reports the skip instead of compacting — and restores both stores. + const { + getSessionContextLimit, + resetSessionContextLimit, + } = await import('@/models/index.js'); + resetAutoCompactSession(); + resetSessionContextLimit(); + setAutoCompactThreshold(50); + + const texts: string[] = []; + const options = createResumeTestOptions({ + onAddToChatQueue: node => { + texts.push(queuedText(node)); + }, + }); + const messages: Message[] = [ + {role: 'user', content: 'Hello, please compact this short transcript.'}, + ]; + options.messages = messages; + let rewritten: Message[] | null = null; + options.setMessages = msgs => { + rewritten = msgs; + }; + + await handleMessageSubmission( + '/compact ?threshold=80 ?context-max=999999999', + options, + ); + + t.true( + texts.some(text => text.includes('below') && text.includes('80%')), + `expected a below-threshold skip message, got: ${JSON.stringify(texts)}`, + ); + t.is(rewritten, null, 'skipped compaction must not rewrite messages'); + t.is( + autoCompactSessionOverrides.threshold, + 50, + 'prior threshold is restored after the gated command', + ); + t.is( + getSessionContextLimit(), + null, + 'once context limit is restored after the gated command', + ); + + resetAutoCompactSession(); + resetSessionContextLimit(); +}); + +test.serial('inline overrides - /compact ?threshold proceeds at or above the threshold', async t => { + // Mirror image: a 1-token once-limit puts any transcript at hundreds of + // percent, so the gate passes and the normal mechanical compaction runs. + const {resetSessionContextLimit} = await import('@/models/index.js'); + resetAutoCompactSession(); + resetSessionContextLimit(); + + const texts: string[] = []; + const options = createResumeTestOptions({ + onAddToChatQueue: node => { + texts.push(queuedText(node)); + }, + }); + options.messages = [ + {role: 'user', content: 'Hello, please compact this short transcript.'}, + ]; + let rewritten: Message[] | null = null; + options.setMessages = msgs => { + rewritten = msgs; + }; + + await handleMessageSubmission('/compact ?threshold=80 ?context-max=1', options); + + t.true( + texts.some(text => text.includes('Compacted')), + `expected a compaction success message, got: ${JSON.stringify(texts)}`, + ); + t.not(rewritten, null, 'compaction above the threshold rewrites messages'); + t.is( + autoCompactSessionOverrides.threshold, + null, + 'no prior threshold means null after restore', + ); + + resetAutoCompactSession(); + resetSessionContextLimit(); +}); diff --git a/source/app/utils/app-util.ts b/source/app/utils/app-util.ts index 8e24c9305..1a339eae6 100644 --- a/source/app/utils/app-util.ts +++ b/source/app/utils/app-util.ts @@ -24,7 +24,20 @@ import {executeBashCommand, formatBashResultForLLM} from '@/tools/execute-bash'; import type {ImageAttachment, LLMClient} from '@/types/core'; import type {Message, MessageSubmissionOptions} from '@/types/index'; import {formatError} from '@/utils/error-formatter'; -import {errorMsg, infoMsg, successMsg} from '@/utils/message-factory'; +import { + applyOnceOverrides, + expandOverrideArgs, + formatInlineToken, + getOnceThreshold, + isRecognizedOverrideKey, + parseInlineOverrides, +} from '@/utils/inline-overrides'; +import { + errorMsg, + infoMsg, + successMsg, + warningMsg, +} from '@/utils/message-factory'; import {clearReadTracker} from '@/utils/read-tracker'; import {clearExpandableToolResults} from '@/utils/tool-result-display'; import {handleCompactCommand} from './handlers/compact-handler'; @@ -694,6 +707,8 @@ async function handleSlashCommand( // live server, not a module import. Intercepting here instead mirrors how // handleCustomCommand (checked just above) already dispatches the other // runtime-discovered command source - project `.nanocoder/commands/` files. + // Both runtime-discovered sources bypass `?key=value` parsing below and + // receive the raw message, so free-text `?word` tokens are never stripped. if ( await handleMCPPromptCommand( commandName, @@ -704,34 +719,82 @@ async function handleSlashCommand( return; } - const commandParts = message.slice(1).trim().split(/\s+/); - - if (await handleCompactCommand(commandParts, options)) return; - if (await handleContextMaxCommand(commandParts, options)) return; - if (await handleCommandCreate(commandParts, options)) return; - if (await handleAgentCreate(commandParts, options)) return; - if (await handleAgentCopy(commandParts, options)) return; - if (await handleToolCreate(commandParts, options)) return; - if (await handleSkillsCreate(commandParts, options)) return; - if (await handleSpecialCommand(commandName, options)) return; - if (await handleCheckpointLoad(commandParts, options)) return; - // Stateful handlers that replay or resume chat flow live alongside each other. - if (await handleResumeCommand(commandParts, options)) return; - if ( - await handleRetryCommand( - [ - commandName, - ...parseCustomCommandArgs(message.slice(commandName.length + 2)), - ], - options, + // ?key=value tokens let a user test a per-session setting for one + // command without committing it to the session. Recognised keys are + // consumed here: once-scoped settings via the session-override stores + // (restored in `finally`), legacy boolean flags by expanding them to + // their `--flag` form. Anything else is forwarded to the command + // handler verbatim (rebuilt into `commandParts`/`cleanedMessage`) so + // each command's own unknown-arg handling runs — and surfaced once as + // a warning so a typo like `?threshhold=80` cannot silently no-op. + const rawParts = message.slice(1).trim().split(/\s+/); + const {args: positional, overrides} = parseInlineOverrides(rawParts.slice(1)); + const recognized = overrides.filter(o => isRecognizedOverrideKey(o.key)); + const passthrough = overrides + .filter(o => !isRecognizedOverrideKey(o.key)) + .map(formatInlineToken); + const expandedFlags = expandOverrideArgs(recognized); + const restTokens = [...positional, ...passthrough, ...expandedFlags].join( + ' ', + ); + const cleanedMessage = restTokens + ? `/${commandName} ${restTokens}` + : `/${commandName}`; + const restoreOnce = await applyOnceOverrides(recognized); + // Explicit once-context for consumers: unlike the session store (which + // cannot tell a once-override apart from a persisted one), this is only + // set when the user typed `?threshold=` on this command. + const onceThreshold = getOnceThreshold(recognized); + try { + const commandParts = [ + commandName, + ...positional, + ...passthrough, + ...expandedFlags, + ]; + + if (passthrough.length > 0) { + const keys = [...new Set(passthrough)].join(', '); + options.onAddToChatQueue( + warningMsg( + `Unknown inline override(s): ${keys} — no once-scoped setting or flag matches. Forwarded to the command unchanged.`, + 'inline-override-unknown', + ), + ); + } + + if (await handleCompactCommand(commandParts, options, onceThreshold)) + return; + if (await handleContextMaxCommand(commandParts, options)) return; + if (await handleCommandCreate(commandParts, options)) return; + if (await handleAgentCreate(commandParts, options)) return; + if (await handleAgentCopy(commandParts, options)) return; + if (await handleToolCreate(commandParts, options)) return; + if (await handleSkillsCreate(commandParts, options)) return; + if (await handleSpecialCommand(commandName, options)) return; + if (await handleCheckpointLoad(commandParts, options)) return; + // Stateful handlers that replay or resume chat flow live alongside each other. + if (await handleResumeCommand(commandParts, options)) return; + if ( + await handleRetryCommand( + [ + commandName, + ...parseCustomCommandArgs( + cleanedMessage.slice(commandName.length + 2), + ), + ], + options, + ) ) - ) - return; - if (handleCopilotLogin(commandParts, options)) return; - if (handleCodexLogin(commandParts, options)) return; - if (handleStatsCommand(commandParts, options)) return; + return; + if (handleCopilotLogin(commandParts, options)) return; + if (handleCodexLogin(commandParts, options)) return; + if (handleStatsCommand(commandParts, options)) return; - await handleBuiltInCommand(message, options); + await handleBuiltInCommand(cleanedMessage, options); + } finally { + restoreOnce(); + } } /** diff --git a/source/app/utils/handlers/compact-handler.ts b/source/app/utils/handlers/compact-handler.ts index 1322f9b91..cc8152ac9 100644 --- a/source/app/utils/handlers/compact-handler.ts +++ b/source/app/utils/handlers/compact-handler.ts @@ -2,10 +2,15 @@ import React from 'react'; import {InfoMessage, SuccessMessage} from '@/components/message-box'; import {getAppConfig} from '@/config/index'; import {DELAY_COMMAND_COMPLETE_MS} from '@/constants'; +import {getModelContextLimit, getSessionContextLimit} from '@/models/index'; import {runLifecycleHooks} from '@/services/lifecycle-hooks'; import {generateKey} from '@/session/key-generator'; import {createTokenizer} from '@/tokenization/index'; -import type {CompressionMode, CompressionStrategy} from '@/types/config'; +import type { + AIProviderConfig, + CompressionMode, + CompressionStrategy, +} from '@/types/config'; import type {Message, MessageSubmissionOptions} from '@/types/index'; import { resolveAutoCompactSettings, @@ -15,6 +20,11 @@ import { } from '@/utils/auto-compact'; import {compressionBackup} from '@/utils/compression-backup'; import {formatError} from '@/utils/error-formatter'; +import { + formatInlineToken, + isRecognizedOverrideKey, + parseInlineOverrides, +} from '@/utils/inline-overrides'; import {summariseWithLLM} from '@/utils/llm-summariser'; import { COMPRESSION_CONSTANTS, @@ -24,12 +34,74 @@ import { import {errorMsg, infoMsg, successMsg} from '@/utils/message-factory'; import {getLastBuiltPrompt} from '@/utils/prompt-builder'; +/** + * Evaluate a once-scoped threshold gate against current usage. Returns the + * usage percentage, or null when it cannot be evaluated (unknown tokenizer, + * unresolvable limit) so the caller fails open to normal compaction. + * + * The token sum mirrors the compaction's own `originalTokenCount` + * (system message included); the limit prefers the session override — + * which already carries a `?context-max` once-value when one was given, + * so the two overrides compose. + */ +async function checkOnceThresholdGate( + deps: { + provider: string; + model: string; + providerConfig?: AIProviderConfig | null; + }, + messages: Message[], +): Promise<{usagePct: number} | null> { + try { + const tokenizer = createTokenizer(deps.provider, deps.model); + try { + const systemMessage: Message = { + role: 'system', + content: getLastBuiltPrompt(), + }; + let totalTokens = 0; + for (const msg of [systemMessage, ...messages]) { + totalTokens += tokenizer.countTokens(msg); + } + let limit = getSessionContextLimit(); + if (limit === null) { + try { + limit = + (await getModelContextLimit(deps.model, { + providerConfig: deps.providerConfig ?? undefined, + })) ?? null; + } catch { + return null; + } + } + if (limit === null || limit <= 0) { + return null; + } + return {usagePct: (totalTokens / limit) * 100}; + } finally { + if (tokenizer.free) { + tokenizer.free(); + } + } + } catch { + return null; + } +} + /** * Handles /compact command. Returns true if handled. + * + * `onceThreshold` carries the explicit once-scoped `?threshold=` value for + * this invocation (see `getOnceThreshold`). Unlike the session-override + * store — which cannot tell a once-override apart from a persisted + * `/compact --threshold` setting — it is only set when the user typed the + * override on this command, so the default unconditional behaviour stays + * untouched for plain `/compact`. */ export async function handleCompactCommand( commandParts: string[], options: MessageSubmissionOptions, + onceThreshold?: number, ): Promise { const { onAddToChatQueue, @@ -39,6 +111,7 @@ export async function handleCompactCommand( provider, model, client, + providerConfig, setIsToolExecuting, } = options; @@ -46,7 +119,19 @@ export async function handleCompactCommand( return false; } - const args = commandParts.slice(1); + // Defensive: the dispatcher already consumes recognised `?key=value` + // tokens, but direct callers (and tests) may pass them through. Consume + // recognised overrides here as well; preserve anything else verbatim so + // it stays visible in args instead of being silently swallowed. + const {args: positional, overrides} = parseInlineOverrides( + commandParts.slice(1), + ); + const args = [ + ...positional, + ...overrides + .filter(o => !isRecognizedOverrideKey(o.key)) + .map(formatInlineToken), + ]; // Explicit flags win; otherwise fall back to the same settings auto-compact // uses (session overrides, tune, then `autoCompact` config). let mode: CompressionMode | null = null; @@ -154,6 +239,33 @@ export async function handleCompactCommand( return true; } + // Once-scoped threshold gate (`/compact ?threshold=80`): skip the + // manual compaction when current usage sits below the threshold, + // mirroring the automatic path's gate in `performAutoCompact`. This + // is what makes the override observable for the `/compact` run + // itself. Best-effort — anything unresolvable fails open to the + // normal unconditional compaction below. + if (onceThreshold !== undefined) { + const gate = await checkOnceThresholdGate( + { + provider, + model, + providerConfig: providerConfig ?? client?.getProviderConfig(), + }, + messages, + ); + if (gate && gate.usagePct < onceThreshold) { + onAddToChatQueue( + infoMsg( + `Context at ${Math.round(gate.usagePct)}% — below the once-threshold of ${onceThreshold}% for this command; skipping compaction.`, + 'compact-once-threshold-skip', + ), + ); + setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); + return true; + } + } + // Same observe-only pre-compact hook the automatic path fires, so a // manual /compact isn't a blind spot for anything archiving context. await runLifecycleHooks('pre-compact', {messageCount: messages.length}); diff --git a/source/utils/auto-compact-session.ts b/source/utils/auto-compact-session.ts new file mode 100644 index 000000000..11e681b0c --- /dev/null +++ b/source/utils/auto-compact-session.ts @@ -0,0 +1,65 @@ +import type {CompressionMode, CompressionStrategy} from '@/types/config'; +import {clampThreshold} from './message-compression'; +import {createSessionOverride} from './session-override'; + +export interface AutoCompactSessionOverrides { + enabled: boolean | null; + threshold: number | null; + mode: CompressionMode | null; + strategy: CompressionStrategy | null; +} + +// Session overrides for auto-compact. `threshold` is clamped to the configured range. + +// Internal override objects (no longer exposed) +const enabledOverride = createSessionOverride(); +const thresholdOverride = createSessionOverride(value => + value !== null ? clampThreshold(value) : null, +); +const modeOverride = createSessionOverride(); +const strategyOverride = createSessionOverride(); + +// Legacy object-style accessor (read by useAppHandlers + performAutoCompact) +// and used by inline overrides parsing +export const autoCompactSessionOverrides: AutoCompactSessionOverrides = + new Proxy({} as AutoCompactSessionOverrides, { + get(_target, prop) { + if (prop === 'enabled') return enabledOverride.get(); + if (prop === 'threshold') return thresholdOverride.get(); + if (prop === 'mode') return modeOverride.get(); + if (prop === 'strategy') return strategyOverride.get(); + return undefined; + }, + set(_target, prop, value) { + if (prop === 'enabled') enabledOverride.set(value); + else if (prop === 'threshold') thresholdOverride.set(value); + else if (prop === 'mode') modeOverride.set(value); + else if (prop === 'strategy') strategyOverride.set(value); + return true; + }, + }); + +export function setAutoCompactEnabled(enabled: boolean | null): void { + enabledOverride.set(enabled); +} + +export function setAutoCompactThreshold(threshold: number | null): void { + thresholdOverride.set(threshold); +} + +export function setAutoCompactMode(mode: CompressionMode | null): void { + modeOverride.set(mode); +} + +export function setAutoCompactStrategy( + strategy: CompressionStrategy | null, +): void { + strategyOverride.set(strategy); +} + +export const resetAutoCompactSession: () => void = function (): void { + enabledOverride.reset(); + thresholdOverride.reset(); + modeOverride.reset(); + strategyOverride.reset(); +}; diff --git a/source/utils/auto-compact.ts b/source/utils/auto-compact.ts index c4ba722c8..833b8b572 100644 --- a/source/utils/auto-compact.ts +++ b/source/utils/auto-compact.ts @@ -11,51 +11,22 @@ import type {AISDKCoreTool, LLMClient, Message} from '@/types/core'; import type {Tokenizer} from '@/types/tokenization'; import {calculateToolDefinitionsTokensFromDefs} from '@/usage/calculator'; import {getLogger} from '@/utils/logging'; +import { + autoCompactSessionOverrides, + resetAutoCompactSession as resetAutoCompactSessionOverrides, +} from './auto-compact-session'; import {compressionBackup} from './compression-backup'; import {summariseWithLLM} from './llm-summariser'; -import { - COMPRESSION_CONSTANTS, - clampThreshold, - compressMessages, -} from './message-compression'; +import {COMPRESSION_CONSTANTS, compressMessages} from './message-compression'; import {filterModelFacing} from './message-visibility'; -import {createSessionOverride} from './session-override'; - -export interface AutoCompactSessionOverrides { - enabled: boolean | null; - threshold: number | null; - mode: CompressionMode | null; - strategy: CompressionStrategy | null; -} -// Session overrides for auto-compact. `threshold` is clamped to the configured range. -const autoCompactSession = { - enabled: createSessionOverride(), - threshold: createSessionOverride(value => - value !== null ? clampThreshold(value) : null, - ), - mode: createSessionOverride(), - strategy: createSessionOverride(), -}; - -// Legacy object-style accessor (read by useAppHandlers + performAutoCompact). -export const autoCompactSessionOverrides: AutoCompactSessionOverrides = - new Proxy({} as AutoCompactSessionOverrides, { - get(_target, prop) { - if (prop === 'enabled') return autoCompactSession.enabled.get(); - if (prop === 'threshold') return autoCompactSession.threshold.get(); - if (prop === 'mode') return autoCompactSession.mode.get(); - if (prop === 'strategy') return autoCompactSession.strategy.get(); - return undefined; - }, - set(_target, prop, value) { - if (prop === 'enabled') autoCompactSession.enabled.set(value); - else if (prop === 'threshold') autoCompactSession.threshold.set(value); - else if (prop === 'mode') autoCompactSession.mode.set(value); - else if (prop === 'strategy') autoCompactSession.strategy.set(value); - return true; - }, - }); +export { + autoCompactSessionOverrides, + setAutoCompactEnabled, + setAutoCompactMode, + setAutoCompactStrategy, + setAutoCompactThreshold, +} from './auto-compact-session'; // Tune's "Aggressive Compact" preset. It sits between the config file and the // user's explicit `/compact` session overrides, so turning tune on or off never @@ -349,33 +320,14 @@ export async function maybeAutoCompact( } } -// Set session override for auto-compact enabled state -export function setAutoCompactEnabled(enabled: boolean | null): void { - autoCompactSession.enabled.set(enabled); -} - -// Set session override for auto-compact threshold -export function setAutoCompactThreshold(threshold: number | null): void { - autoCompactSession.threshold.set(threshold); -} - -// Set session override for auto-compact mode -export function setAutoCompactMode(mode: CompressionMode | null): void { - autoCompactSession.mode.set(mode); -} - -// Set session override for auto-compact strategy -export function setAutoCompactStrategy( - strategy: CompressionStrategy | null, -): void { - autoCompactSession.strategy.set(strategy); -} - -// Reset all session overrides +// Set session override helpers live in `./auto-compact-session` (a +// lightweight module without the tokenizer/config graph, so the inline +// `?key=value` parser specs can import them without timing out). They are +// re-exported above so existing importers keep working. +// +// `resetAutoCompactSession` is defined (not re-exported) here because it +// additionally clears tune's aggressive-compact preset. export function resetAutoCompactSession(): void { - autoCompactSession.enabled.reset(); - autoCompactSession.threshold.reset(); - autoCompactSession.mode.reset(); - autoCompactSession.strategy.reset(); + resetAutoCompactSessionOverrides(); tuneAggressiveCompact = false; } diff --git a/source/utils/inline-overrides.spec.ts b/source/utils/inline-overrides.spec.ts new file mode 100644 index 000000000..d3736452b --- /dev/null +++ b/source/utils/inline-overrides.spec.ts @@ -0,0 +1,300 @@ +import test from 'ava'; +import { + autoCompactSessionOverrides, + resetAutoCompactSession, + setAutoCompactEnabled, + setAutoCompactThreshold, +} from './auto-compact-session.js'; +import {getSessionContextLimit, resetSessionContextLimit, setSessionContextLimit} from '@/models/index'; +import { + applyOnceOverrides, + expandOverrideArgs, + formatInlineToken, + getOnceThreshold, + isRecognizedOverrideKey, + parseInlineOverrides, +} from './inline-overrides.js'; + +console.log('\ninline-overrides.spec.ts'); + +// ============================================================================ +// parseInlineOverrides +// ============================================================================ + +test('parseInlineOverrides returns args unchanged when no overrides are present', t => { + const {args, overrides} = parseInlineOverrides(['--preview', '--llm']); + t.deepEqual(args, ['--preview', '--llm']); + t.deepEqual(overrides, []); +}); + +test('parseInlineOverrides extracts a single key=value override', t => { + const {args, overrides} = parseInlineOverrides(['?threshold=80']); + t.deepEqual(args, []); + t.deepEqual(overrides, [{key: 'threshold', value: '80'}]); +}); + +test('parseInlineOverrides mixes positional args and overrides', t => { + const {args, overrides} = parseInlineOverrides([ + '--mechanical', + '?threshold=80', + '--preview', + ]); + t.deepEqual(args, ['--mechanical', '--preview']); + t.deepEqual(overrides, [{key: 'threshold', value: '80'}]); +}); + +test('parseInlineOverrides supports bare ?flag as a boolean override', t => { + const {args, overrides} = parseInlineOverrides(['?preview', '--llm']); + t.deepEqual(args, ['--llm']); + t.deepEqual(overrides, [{key: 'preview', value: true}]); +}); + +test('parseInlineOverrides preserves values containing extra equals signs', t => { + const {args, overrides} = parseInlineOverrides(['?config=key=value']); + t.deepEqual(args, []); + t.deepEqual(overrides, [{key: 'config', value: 'key=value'}]); +}); + +test('parseInlineOverrides accepts dotted, dashed, and underscored keys', t => { + const {overrides} = parseInlineOverrides([ + '?auto_compact=on', + '?compact-threshold=85', + '?max.tokens=4096', + ]); + t.deepEqual(overrides, [ + {key: 'auto_compact', value: 'on'}, + {key: 'compact-threshold', value: '85'}, + {key: 'max.tokens', value: '4096'}, + ]); +}); + +test('parseInlineOverrides rejects invalid keys by leaving the token in args', t => { + const {args, overrides} = parseInlineOverrides(['?1bad', '?']); + t.deepEqual(args, ['?1bad', '?']); + t.deepEqual(overrides, []); +}); + +test('parseInlineOverrides handles an empty args list', t => { + const {args, overrides} = parseInlineOverrides([]); + t.deepEqual(args, []); + t.deepEqual(overrides, []); +}); + +// ============================================================================ +// expandOverrideArgs +// ============================================================================ + +test('expandOverrideArgs expands recognised boolean flags to single tokens', t => { + t.deepEqual(expandOverrideArgs([{key: 'preview', value: true}]), ['--preview']); + t.deepEqual(expandOverrideArgs([{key: 'preview', value: false}]), []); +}); + +test('expandOverrideArgs maps truthy valued booleans to a single flag token', t => { + t.deepEqual(expandOverrideArgs([{key: 'preview', value: 'yes'}]), [ + '--preview', + ]); + t.deepEqual(expandOverrideArgs([{key: 'preview', value: 'on'}]), [ + '--preview', + ]); + t.deepEqual(expandOverrideArgs([{key: 'preview', value: 'no'}]), []); + t.deepEqual(expandOverrideArgs([{key: 'preview', value: 'off'}]), []); +}); + +test('expandOverrideArgs ignores unknown override keys', t => { + t.deepEqual( + expandOverrideArgs([{key: 'unknown', value: '42'}]), + [], + ); +}); + +test('expandOverrideArgs preserves order', t => { + t.deepEqual( + expandOverrideArgs([ + {key: 'preview', value: true}, + {key: 'llm', value: true}, + {key: 'mechanical', value: true}, + ]), + ['--preview', '--llm', '--mechanical'], + ); +}); + +// ============================================================================ +// applyOnceOverrides - apply + restore round-trip +// ============================================================================ + +test.beforeEach(() => { + // Wipe every store before each test so a leaked override cannot bleed + // into a sibling case. + resetAutoCompactSession(); + resetSessionContextLimit(); +}); + +test('applyOnceOverrides - threshold override applies and restores the prior value', async t => { + setAutoCompactThreshold(75); + + const restore = await applyOnceOverrides([{key: 'threshold', value: '85'}]); + t.is(autoCompactSessionOverrides.threshold, 85, 'override is applied'); + + restore(); + t.is(autoCompactSessionOverrides.threshold, 75, 'prior value is restored'); +}); + +test('applyOnceOverrides - context-max override honours the k suffix and restores the prior value', async t => { + setSessionContextLimit(8192); + + const restore = await applyOnceOverrides([ + {key: 'context-max', value: '128k'}, + ]); + t.is(getSessionContextLimit(), 128000, 'k suffix multiplies by 1000'); + + restore(); + t.is(getSessionContextLimit(), 8192, 'prior value is restored'); +}); + +test('applyOnceOverrides - auto-compact enabled override applies and restores the prior value', async t => { + setAutoCompactEnabled(false); + + const restore = await applyOnceOverrides([ + {key: 'auto-compact', value: 'true'}, + ]); + t.is(autoCompactSessionOverrides.enabled, true, 'override is applied'); + + restore(); + t.is(autoCompactSessionOverrides.enabled, false, 'prior value is restored'); +}); + +test('applyOnceOverrides - restore when no prior override was set clears to null', async t => { + t.is(autoCompactSessionOverrides.threshold, null); + t.is(getSessionContextLimit(), null); + + const restore = await applyOnceOverrides([ + {key: 'threshold', value: '80'}, + {key: 'context-max', value: '128k'}, + ]); + t.is(autoCompactSessionOverrides.threshold, 80); + t.is(getSessionContextLimit(), 128000); + + restore(); + t.is(autoCompactSessionOverrides.threshold, null, 'no prior → null after restore'); + t.is(getSessionContextLimit(), null, 'no prior → null after restore'); +}); + +// ============================================================================ +// applyOnceOverrides - best-effort validation (no apply, no restore, no error) +// ============================================================================ + +test('applyOnceOverrides returns a no-op restore when no overrides are present', async t => { + const restore = await applyOnceOverrides([]); + t.notThrows(() => restore()); + t.notThrows(() => restore()); +}); + +test('applyOnceOverrides ignores unknown override keys', async t => { + const restore = await applyOnceOverrides([{key: 'unknown', value: '1'}]); + t.notThrows(() => restore()); +}); + +test('applyOnceOverrides ignores threshold values that fail to parse', async t => { + const restore = await applyOnceOverrides([{key: 'threshold', value: 'abc'}]); + // No setter call was made, restore should still be a safe no-op. + t.notThrows(() => restore()); +}); + +test('applyOnceOverrides ignores context-max values that fail to parse', async t => { + const restore = await applyOnceOverrides([ + {key: 'context-max', value: 'not-a-number'}, + ]); + t.notThrows(() => restore()); +}); + +test('applyOnceOverrides ignores out-of-range threshold values', async t => { + setAutoCompactThreshold(75); + + const restore = await applyOnceOverrides([{key: 'threshold', value: '40'}]); + t.is( + autoCompactSessionOverrides.threshold, + 75, + 'out-of-range threshold is skipped; prior value is preserved', + ); + + restore(); + t.is( + autoCompactSessionOverrides.threshold, + 75, + 'no apply → no restore, prior value is still 75', + ); +}); + +// ============================================================================ +// isRecognizedOverrideKey / formatInlineToken +// ============================================================================ + +test('isRecognizedOverrideKey accepts once-scoped settings and legacy flags', t => { + for (const key of [ + 'threshold', + 'auto-compact', + 'context-max', + 'preview', + 'llm', + 'mechanical', + 'aggressive', + 'conservative', + 'auto-on', + 'auto-off', + ]) { + t.true(isRecognizedOverrideKey(key), `${key} is recognised`); + } +}); + +test('isRecognizedOverrideKey rejects unknown keys', t => { + for (const key of ['unknown', 'threshhold', 'config', 'once']) { + t.false(isRecognizedOverrideKey(key), `${key} is not recognised`); + } +}); + +test('formatInlineToken rebuilds the original ?token', t => { + t.is(formatInlineToken({key: 'preview', value: true}), '?preview'); + t.is(formatInlineToken({key: 'threshold', value: '80'}), '?threshold=80'); + t.is( + formatInlineToken({key: 'config', value: 'key=value'}), + '?config=key=value', + ); +}); + +test('expandOverrideArgs forwards auto-on and auto-off to their --flags', t => { + t.deepEqual(expandOverrideArgs([{key: 'auto-on', value: true}]), [ + '--auto-on', + ]); + t.deepEqual(expandOverrideArgs([{key: 'auto-off', value: true}]), [ + '--auto-off', + ]); +}); + +// ============================================================================ +// getOnceThreshold +// ============================================================================ + +test('getOnceThreshold returns undefined when no threshold override is present', t => { + t.is(getOnceThreshold([]), undefined); + t.is(getOnceThreshold([{key: 'context-max', value: '200k'}]), undefined); +}); + +test('getOnceThreshold parses and rounds a valid threshold', t => { + t.is(getOnceThreshold([{key: 'threshold', value: '80'}]), 80); +}); + +test('getOnceThreshold lets the last valid threshold win', t => { + t.is( + getOnceThreshold([ + {key: 'threshold', value: '80'}, + {key: 'threshold', value: '90'}, + ]), + 90, + ); +}); + +test('getOnceThreshold ignores unparseable and out-of-range values', t => { + t.is(getOnceThreshold([{key: 'threshold', value: 'abc'}]), undefined); + t.is(getOnceThreshold([{key: 'threshold', value: '40'}]), undefined); + t.is(getOnceThreshold([{key: 'threshold', value: '99'}]), undefined); +}); diff --git a/source/utils/inline-overrides.ts b/source/utils/inline-overrides.ts new file mode 100644 index 000000000..252ef09d2 --- /dev/null +++ b/source/utils/inline-overrides.ts @@ -0,0 +1,286 @@ +/** + * Parse inline `?key=value` tokens out of a slash command's args array, plus + * the helpers used by the slash-command dispatcher to apply them. + * + * Lets a user test a per-session setting for a single command without + * committing it to the global session state, e.g. + * + * /usage ?context-max=200k + * /compact ?preview + * + * Only arguments whose first character is `?` are considered; anything else + * is preserved as-is in the returned `args`. A bare `?flag` (no `=`) becomes + * `{ key: 'flag', value: true }` so handlers can opt into boolean toggles + * like `/compact ?preview`. + * + * Scope notes (see #1151 review): the one key with a synchronous reader + * during slash-command dispatch is `context-max` on `/usage` (which reads + * `getSessionContextLimit()` inside its awaited handler). `threshold` is + * additionally observed by `/compact` via an explicit once-threshold gate + * (see `handleCompactCommand`'s `onceThreshold` parameter): when present, + * manual compaction is skipped with an informational message if current + * usage sits below the threshold, mirroring the automatic path's gate. + * `auto-compact` is applied through the same session-override plumbing and + * restored to its prior value, but no built-in slash command currently + * reads it synchronously during dispatch — the automatic compaction path + * (`maybeAutoCompact`) only runs on chat turns. It is kept as reserved + * plumbing (with round-trip apply/restore tests) rather than advertised + * as a working command example. + * + * Unknown `?foo=bar` keys are never consumed: they are excluded from the + * session-override stores and from flag expansion, forwarded to the command + * handler verbatim so each command's own unknown-arg handling runs, and + * surfaced once by the dispatcher as a warning so a typo cannot silently + * no-op. + * + * Value validation in `applyOnceOverrides` is best-effort: unparseable or + * out-of-range values are ignored (no apply, no restore, no error). For + * strict validation with an explicit error message, use the regular + * `--flag value` form of the same argument. + */ + +import {COMPRESSION_CONSTANTS} from './message-compression'; + +export interface InlineOverride { + key: string; + value: string | boolean; +} + +export interface ParseInlineOverridesResult { + args: string[]; + overrides: InlineOverride[]; +} + +const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/; + +export function parseInlineOverrides( + rawArgs: readonly string[], +): ParseInlineOverridesResult { + const args: string[] = []; + const overrides: InlineOverride[] = []; + + for (const arg of rawArgs) { + if (!arg.startsWith('?')) { + args.push(arg); + continue; + } + + // Strip the leading `?` and split on the first `=`. + const body = arg.slice(1); + const eq = body.indexOf('='); + let key: string; + let value: string | boolean; + if (eq === -1) { + key = body; + value = true; + } else { + key = body.slice(0, eq); + value = body.slice(eq + 1); + } + + if (!KEY_PATTERN.test(key)) { + // Not a recognised override shape: keep it in args so the user + // gets a normal 'unknown arg' error from the command rather than + // the parser silently swallowing it. + args.push(arg); + continue; + } + + overrides.push({key, value}); + } + + return {args, overrides}; +} + +/** + * Map of `?flag` names that should be expanded into the corresponding + * `--flag value` CLI args so existing slash-command handlers can stay + * oblivious to the override feature. Boolean overrides (`?preview`) expand + * to a single `--preview` token. Keys that have once-scoped overrides (e.g. + * `?auto-compact`, `?threshold`) are NOT expanded here - they're only handled + * via `applyOnceOverrides`. + */ +const LEGACY_FLAG_NAMES: Record = { + preview: '--preview', + llm: '--llm', + mechanical: '--mechanical', + aggressive: '--aggressive', + conservative: '--conservative', + 'auto-on': '--auto-on', + 'auto-off': '--auto-off', +}; + +/** Once-scoped keys consumed by `applyOnceOverrides` (never forwarded). */ +const ONCE_SCOPED_KEYS = new Set(['threshold', 'auto-compact', 'context-max']); + +/** + * Whether the dispatcher recognises this override key: either a once-scoped + * session-override key or a legacy boolean flag. Anything else is forwarded + * to the command handler verbatim (see `formatInlineToken`) so typos and + * command-specific keys stay visible instead of silently vanishing. + */ +export function isRecognizedOverrideKey(key: string): boolean { + return ONCE_SCOPED_KEYS.has(key) || key in LEGACY_FLAG_NAMES; +} + +/** + * Rebuild the original `?key` / `?key=value` token from a parsed override. + * Lossless for everything `parseInlineOverrides` produces: a boolean `true` + * came from a bare `?flag`, any other value followed an `=`. + */ +export function formatInlineToken({key, value}: InlineOverride): string { + return value === true ? `?${key}` : `?${key}=${value}`; +} + +const TRUE_STRINGS = new Set(['1', 'true', 'yes', 'on']); + +function toBoolean(value: string | boolean): boolean { + if (typeof value === 'boolean') return value; + return TRUE_STRINGS.has(value.toLowerCase()); +} + +/** + * Expand a list of `?key=value` overrides into the corresponding + * `--key` CLI args. Used when the override should flow through to + * the existing command handler as if the user had typed the long form. + * Every entry in `LEGACY_FLAG_NAMES` is a boolean flag, so string values + * go through `toBoolean` instead of being forwarded as a stray positional + * token (e.g. `?preview=yes` becomes `--preview`, not `--preview yes`). + */ +export function expandOverrideArgs( + overrides: readonly InlineOverride[], +): string[] { + const out: string[] = []; + for (const {key, value} of overrides) { + const flag = LEGACY_FLAG_NAMES[key]; + if (!flag) continue; + if (toBoolean(value)) out.push(flag); + } + return out; +} + +/** + * `applyOnceOverrides` writes each recognised override into the existing + * session-override stores (see `source/utils/auto-compact-session.ts` and + * `source/models/index.ts`) and returns a `restore` callback. The caller + * MUST invoke `restore` once the command finishes - typically in a + * `finally` block - so the override only applies to the single command + * the user typed it on. + * + * Unknown override keys are ignored here. The dispatcher uses + * `expandOverrideArgs` to forward them to the command handler as ordinary + * flags, so `?preview` still works for any command that understands it. + * + * The `restore` callback reverts each applied override to its **prior + * value** (not to `null`), so a pre-existing session override is preserved + * across the once-scoped change. Unparseable or out-of-range values are + * silently skipped (no apply, no restore). + * + * The session-override setters are pulled in lazily. The dispatcher is + * the only caller of this function and only runs once per slash command, + * so paying the import cost on first use keeps the parser specs free + * from the heavy chat-handler / config / tokenization init graph. + */ +export async function applyOnceOverrides( + overrides: readonly InlineOverride[], +): Promise<() => void> { + const restorations: Array<() => void> = []; + + if (overrides.length === 0) { + return () => {}; + } + + // Only import the setter modules when we actually have an override + // to apply. Keeps `parseInlineOverrides` testable in isolation. + // (COMPRESSION_CONSTANTS is a static import: message-compression has no + // runtime imports of its own, so it adds nothing to the init graph.) + const [ + { + autoCompactSessionOverrides, + setAutoCompactEnabled, + setAutoCompactThreshold, + }, + models, + {parseContextLimit}, + ] = await Promise.all([ + import('./auto-compact-session.js'), + import('@/models/index.js'), + import('./parse-context-limit.js'), + ]); + + for (const {key, value} of overrides) { + switch (key) { + case 'threshold': { + const numeric = Number.parseFloat(String(value)); + // Out-of-range values are silently skipped (no apply, no + // restore) - matches the documented "best-effort" validation. + if ( + Number.isNaN(numeric) || + numeric < COMPRESSION_CONSTANTS.MIN_THRESHOLD_PERCENT || + numeric > COMPRESSION_CONSTANTS.MAX_THRESHOLD_PERCENT + ) + break; + const prior = autoCompactSessionOverrides.threshold; + setAutoCompactThreshold(Math.round(numeric)); + restorations.push(() => setAutoCompactThreshold(prior)); + break; + } + case 'auto-compact': { + const bool = toBoolean(value); + const prior = autoCompactSessionOverrides.enabled; + setAutoCompactEnabled(bool); + restorations.push(() => setAutoCompactEnabled(prior)); + break; + } + case 'context-max': { + const numeric = parseContextLimit(String(value)); + if (numeric === null) break; + const prior = models.getSessionContextLimit(); + models.setSessionContextLimit(numeric); + restorations.push(() => models.setSessionContextLimit(prior)); + break; + } + // No default: keys not listed here fall through to + // `expandOverrideArgs` in the dispatcher. + } + } + + return () => { + for (const restore of restorations) { + try { + restore(); + } catch { + // A single failed restore (e.g. someone reset the override + // concurrently) should not block the rest from running. + } + } + }; +} + +/** + * Extract the once-scoped `threshold` value from already-parsed overrides + * for explicit consumers (`/compact`'s gate). Mirrors the validation in + * `applyOnceOverrides` — last valid value wins, anything unparseable or + * out of range yields `undefined` (fail open: the command runs un-gated). + * Returns `undefined` when no `?threshold` override is present so callers + * can tell "no once-threshold" apart from a persisted session override and + * leave the default unconditional behaviour untouched. + */ +export function getOnceThreshold( + overrides: readonly InlineOverride[], +): number | undefined { + let found: number | undefined; + for (const {key, value} of overrides) { + if (key !== 'threshold') continue; + const numeric = Number.parseFloat(String(value)); + if ( + Number.isNaN(numeric) || + numeric < COMPRESSION_CONSTANTS.MIN_THRESHOLD_PERCENT || + numeric > COMPRESSION_CONSTANTS.MAX_THRESHOLD_PERCENT + ) { + continue; + } + found = Math.round(numeric); + } + return found; +}