From b60164a05368453eddbdbd19806b560aacda4e00 Mon Sep 17 00:00:00 2001 From: amnesiaof Date: Fri, 2 Oct 2026 18:27:58 +0300 Subject: [PATCH 1/5] refactor(config): replace the type ladder with a rule table validateConfigTypes was about 440 lines of typeof checks, one branch per config key, beside a hand maintained 44 entry list of dotted key strings that had to stay in sync with the interfaces, the defaults and the JSON schema by hand. It is a declarative rule table now, and the set of known keys is derived from defaultConfig rather than restated. The three keys that are valid but absent from the defaults are listed separately. Five merge functions plus deepCloneConfig became one recursive merge over structuredClone. getConfigPaths lost three copies of the same existsSync ternary. --- lib/config.ts | 858 +++++++++++++------------------------------------- 1 file changed, 217 insertions(+), 641 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index 792b7ca3..c5e9a294 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -100,51 +100,11 @@ const DEFAULT_PROTECTED_TOOLS = [ const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["task", "skill", "todowrite", "todoread"] -export const VALID_CONFIG_KEYS = new Set([ - "$schema", - "enabled", - "autoUpdate", - "debug", - "showUpdateToasts", - "pruneNotification", - "pruneNotificationType", - "turnProtection", - "turnProtection.enabled", - "turnProtection.turns", - "experimental", - "experimental.allowSubAgents", - "experimental.customPrompts", - "protectedFilePatterns", - "commands", - "commands.enabled", - "commands.protectedTools", - "manualMode", - "manualMode.enabled", - "manualMode.automaticStrategies", - "compress", - "compress.mode", - "compress.permission", - "compress.showCompression", - "compress.summaryBuffer", - "compress.maxContextLimit", - "compress.minContextLimit", - "compress.modelMaxLimits", - "compress.modelMinLimits", - "compress.nudgeFrequency", - "compress.iterationNudgeThreshold", - "compress.nudgeForce", - "compress.protectedTools", - "compress.protectTags", - "compress.protectUserMessages", - "strategies", - "strategies.deduplication", - "strategies.deduplication.enabled", - "strategies.deduplication.protectedTools", - "strategies.purgeErrors", - "strategies.purgeErrors.enabled", - "strategies.purgeErrors.turns", - "strategies.purgeErrors.protectedTools", -]) +/** + * Keys that are valid but absent from `defaultConfig`: the `$schema` marker we + * write into generated files, and the two optional per-model limit maps. + */ +const OPTIONAL_CONFIG_KEYS = ["$schema", "compress.modelMaxLimits", "compress.modelMinLimits"] function getConfigKeyPaths(obj: Record, prefix = ""): string[] { const keys: string[] = [] @@ -165,8 +125,8 @@ function getConfigKeyPaths(obj: Record, prefix = ""): string[] { } export function getInvalidConfigKeys(userConfig: Record): string[] { - const userKeys = getConfigKeyPaths(userConfig) - return userKeys.filter((key) => !VALID_CONFIG_KEYS.has(key)) + const known = new Set([...getConfigKeyPaths(defaultConfig), ...OPTIONAL_CONFIG_KEYS]) + return getConfigKeyPaths(userConfig).filter((key) => !known.has(key)) } interface ValidationError { @@ -175,447 +135,178 @@ interface ValidationError { actual: string } -export function validateConfigTypes(config: Record): ValidationError[] { - const errors: ValidationError[] = [] - - if (config.enabled !== undefined && typeof config.enabled !== "boolean") { - errors.push({ key: "enabled", expected: "boolean", actual: typeof config.enabled }) - } +/** + * A rule reports `null` for a valid value, a bare string for the rule's fixed + * `expected` label, or an explicit `{ expected, actual }` pair for the handful of + * checks whose message varies with the offending value. + */ +type Violation = string | { expected: string; actual: string } - if (config.autoUpdate !== undefined && typeof config.autoUpdate !== "boolean") { - errors.push({ key: "autoUpdate", expected: "boolean", actual: typeof config.autoUpdate }) - } - - if (config.debug !== undefined && typeof config.debug !== "boolean") { - errors.push({ key: "debug", expected: "boolean", actual: typeof config.debug }) - } +interface Rule { + expected: string + check: (value: unknown) => Violation | null +} - if (config.pruneNotification !== undefined) { - const validValues = ["off", "minimal", "detailed"] - if (!validValues.includes(config.pruneNotification)) { - errors.push({ - key: "pruneNotification", - expected: '"off" | "minimal" | "detailed"', - actual: JSON.stringify(config.pruneNotification), - }) - } - } +const booleanRule: Rule = { + expected: "boolean", + check: (value) => (typeof value === "boolean" ? null : typeof value), +} - if (config.pruneNotificationType !== undefined) { - const validValues = ["chat", "toast"] - if (!validValues.includes(config.pruneNotificationType)) { - errors.push({ - key: "pruneNotificationType", - expected: '"chat" | "toast"', - actual: JSON.stringify(config.pruneNotificationType), - }) - } - } +const objectRule = (skipFalsy = false): Rule => ({ + expected: "object", + check: (value) => + skipFalsy && !value + ? null + : typeof value === "object" && value !== null && !Array.isArray(value) + ? null + : typeof value, +}) + +const toolListRule: Rule = { + expected: "string[]", + check: (value) => (Array.isArray(value) ? null : typeof value), +} - if (config.protectedFilePatterns !== undefined) { - if (!Array.isArray(config.protectedFilePatterns)) { - errors.push({ - key: "protectedFilePatterns", - expected: "string[]", - actual: typeof config.protectedFilePatterns, - }) - } else if (!config.protectedFilePatterns.every((v: unknown) => typeof v === "string")) { - errors.push({ - key: "protectedFilePatterns", - expected: "string[]", - actual: "non-string entries", - }) - } - } +const patternListRule: Rule = { + expected: "string[]", + check: (value) => + Array.isArray(value) + ? value.every((entry) => typeof entry === "string") + ? null + : "non-string entries" + : typeof value, +} - if (config.turnProtection) { - if ( - config.turnProtection.enabled !== undefined && - typeof config.turnProtection.enabled !== "boolean" - ) { - errors.push({ - key: "turnProtection.enabled", - expected: "boolean", - actual: typeof config.turnProtection.enabled, - }) - } +const enumerationRule = (...allowed: string[]): Rule => ({ + expected: allowed.map((value) => `"${value}"`).join(" | "), + check: (value) => (allowed.includes(value as string) ? null : JSON.stringify(value)), +}) + +/** `min` adds the "below the usable floor" warning the value gets clamped away from. */ +const numberRule = (min?: { label: string; suffix: string }): Rule => ({ + expected: "number", + check: (value) => + typeof value !== "number" + ? typeof value + : min && value < 1 + ? { expected: min.label, actual: `${value}${min.suffix}` } + : null, +}) + +const limitRule: Rule = { + expected: 'number | "${number}%"', + check: (value) => + typeof value === "number" || (typeof value === "string" && value.endsWith("%")) + ? null + : JSON.stringify(value), +} - if ( - config.turnProtection.turns !== undefined && - typeof config.turnProtection.turns !== "number" - ) { - errors.push({ - key: "turnProtection.turns", - expected: "number", - actual: typeof config.turnProtection.turns, - }) - } - if (typeof config.turnProtection.turns === "number" && config.turnProtection.turns < 1) { - errors.push({ - key: "turnProtection.turns", - expected: "positive number (>= 1)", - actual: `${config.turnProtection.turns}`, - }) - } - } +const modelLimitMapRule: Rule = { + expected: "Record", + check: (value) => + typeof value === "object" && value !== null && !Array.isArray(value) ? null : typeof value, +} - const experimental = config.experimental - if (experimental !== undefined) { - if ( - typeof experimental !== "object" || - experimental === null || - Array.isArray(experimental) - ) { - errors.push({ - key: "experimental", - expected: "object", - actual: typeof experimental, - }) - } else { - if ( - experimental.allowSubAgents !== undefined && - typeof experimental.allowSubAgents !== "boolean" - ) { - errors.push({ - key: "experimental.allowSubAgents", - expected: "boolean", - actual: typeof experimental.allowSubAgents, - }) - } +const CLAMPED_POSITIVE = { label: "positive number (>= 1)", suffix: " (will be clamped to 1)" } + +/** + * Every accepted key with its type check, keyed by the same dotted paths + * `getInvalidConfigKeys` uses. `dcp.schema.json` carries the same shape for + * editors; this table exists to warn at load time. + */ +const CONFIG_RULES: Record = { + enabled: booleanRule, + autoUpdate: booleanRule, + debug: booleanRule, + pruneNotification: enumerationRule("off", "minimal", "detailed"), + pruneNotificationType: enumerationRule("chat", "toast"), + protectedFilePatterns: patternListRule, + turnProtection: objectRule(true), + "turnProtection.enabled": booleanRule, + "turnProtection.turns": numberRule({ label: "positive number (>= 1)", suffix: "" }), + experimental: objectRule(), + "experimental.allowSubAgents": booleanRule, + "experimental.customPrompts": booleanRule, + commands: objectRule(), + "commands.enabled": booleanRule, + "commands.protectedTools": toolListRule, + manualMode: objectRule(), + "manualMode.enabled": booleanRule, + "manualMode.automaticStrategies": booleanRule, + compress: objectRule(), + "compress.mode": enumerationRule("range", "message"), + "compress.summaryBuffer": booleanRule, + "compress.nudgeFrequency": numberRule(CLAMPED_POSITIVE), + "compress.iterationNudgeThreshold": numberRule(CLAMPED_POSITIVE), + "compress.nudgeForce": enumerationRule("strong", "soft"), + "compress.protectedTools": toolListRule, + "compress.protectTags": booleanRule, + "compress.protectUserMessages": booleanRule, + "compress.maxContextLimit": limitRule, + "compress.minContextLimit": limitRule, + "compress.modelMaxLimits": modelLimitMapRule, + "compress.modelMinLimits": modelLimitMapRule, + "compress.permission": enumerationRule("ask", "allow", "deny"), + "compress.showCompression": booleanRule, + "strategies.deduplication.enabled": booleanRule, + "strategies.deduplication.protectedTools": toolListRule, + "strategies.purgeErrors.enabled": booleanRule, + "strategies.purgeErrors.turns": numberRule(CLAMPED_POSITIVE), + "strategies.purgeErrors.protectedTools": toolListRule, +} - if ( - experimental.customPrompts !== undefined && - typeof experimental.customPrompts !== "boolean" - ) { - errors.push({ - key: "experimental.customPrompts", - expected: "boolean", - actual: typeof experimental.customPrompts, - }) - } - } +function readConfigPath(config: Record, path: string): unknown { + let value: unknown = config + for (const segment of path.split(".")) { + if (typeof value !== "object" || value === null) return undefined + value = (value as Record)[segment] } + return value +} - const commands = config.commands - if (commands !== undefined) { - if (typeof commands !== "object" || commands === null || Array.isArray(commands)) { - errors.push({ - key: "commands", - expected: "object", - actual: typeof commands, - }) - } else { - if (commands.enabled !== undefined && typeof commands.enabled !== "boolean") { - errors.push({ - key: "commands.enabled", - expected: "boolean", - actual: typeof commands.enabled, - }) - } - if (commands.protectedTools !== undefined && !Array.isArray(commands.protectedTools)) { - errors.push({ - key: "commands.protectedTools", - expected: "string[]", - actual: typeof commands.protectedTools, - }) - } - } - } +/** Per-model entries of the two dynamic limit maps, keyed `compress.modelMaxLimits.`. */ +function validateModelLimitEntries(config: Record): ValidationError[] { + const errors: ValidationError[] = [] + const compress = readConfigPath(config, "compress") + if (typeof compress !== "object" || compress === null || Array.isArray(compress)) return errors - const manualMode = config.manualMode - if (manualMode !== undefined) { - if (typeof manualMode !== "object" || manualMode === null || Array.isArray(manualMode)) { - errors.push({ - key: "manualMode", - expected: "object", - actual: typeof manualMode, - }) - } else { - if (manualMode.enabled !== undefined && typeof manualMode.enabled !== "boolean") { - errors.push({ - key: "manualMode.enabled", - expected: "boolean", - actual: typeof manualMode.enabled, - }) - } + for (const field of ["modelMaxLimits", "modelMinLimits"] as const) { + const limits = (compress as Record)[field] + if (typeof limits !== "object" || limits === null || Array.isArray(limits)) continue - if ( - manualMode.automaticStrategies !== undefined && - typeof manualMode.automaticStrategies !== "boolean" - ) { + for (const [modelKey, limit] of Object.entries(limits as Record)) { + const isPercent = typeof limit === "string" && /^\d+(?:\.\d+)?%$/.test(limit) + if (typeof limit !== "number" && !isPercent) { errors.push({ - key: "manualMode.automaticStrategies", - expected: "boolean", - actual: typeof manualMode.automaticStrategies, + key: `compress.${field}.${modelKey}`, + expected: 'number | "${number}%"', + actual: JSON.stringify(limit), }) } } } - const compress = config.compress - if (compress !== undefined) { - if (typeof compress !== "object" || compress === null || Array.isArray(compress)) { - errors.push({ - key: "compress", - expected: "object", - actual: typeof compress, - }) - } else { - if ( - compress.mode !== undefined && - compress.mode !== "range" && - compress.mode !== "message" - ) { - errors.push({ - key: "compress.mode", - expected: '"range" | "message"', - actual: JSON.stringify(compress.mode), - }) - } - - if ( - compress.summaryBuffer !== undefined && - typeof compress.summaryBuffer !== "boolean" - ) { - errors.push({ - key: "compress.summaryBuffer", - expected: "boolean", - actual: typeof compress.summaryBuffer, - }) - } - - if ( - compress.nudgeFrequency !== undefined && - typeof compress.nudgeFrequency !== "number" - ) { - errors.push({ - key: "compress.nudgeFrequency", - expected: "number", - actual: typeof compress.nudgeFrequency, - }) - } - - if (typeof compress.nudgeFrequency === "number" && compress.nudgeFrequency < 1) { - errors.push({ - key: "compress.nudgeFrequency", - expected: "positive number (>= 1)", - actual: `${compress.nudgeFrequency} (will be clamped to 1)`, - }) - } - - if ( - compress.iterationNudgeThreshold !== undefined && - typeof compress.iterationNudgeThreshold !== "number" - ) { - errors.push({ - key: "compress.iterationNudgeThreshold", - expected: "number", - actual: typeof compress.iterationNudgeThreshold, - }) - } - - if ( - compress.nudgeForce !== undefined && - compress.nudgeForce !== "strong" && - compress.nudgeForce !== "soft" - ) { - errors.push({ - key: "compress.nudgeForce", - expected: '"strong" | "soft"', - actual: JSON.stringify(compress.nudgeForce), - }) - } - - if (compress.protectedTools !== undefined && !Array.isArray(compress.protectedTools)) { - errors.push({ - key: "compress.protectedTools", - expected: "string[]", - actual: typeof compress.protectedTools, - }) - } - - if (compress.protectTags !== undefined && typeof compress.protectTags !== "boolean") { - errors.push({ - key: "compress.protectTags", - expected: "boolean", - actual: typeof compress.protectTags, - }) - } - - if ( - compress.protectUserMessages !== undefined && - typeof compress.protectUserMessages !== "boolean" - ) { - errors.push({ - key: "compress.protectUserMessages", - expected: "boolean", - actual: typeof compress.protectUserMessages, - }) - } - - if ( - typeof compress.iterationNudgeThreshold === "number" && - compress.iterationNudgeThreshold < 1 - ) { - errors.push({ - key: "compress.iterationNudgeThreshold", - expected: "positive number (>= 1)", - actual: `${compress.iterationNudgeThreshold} (will be clamped to 1)`, - }) - } - - const validateLimitValue = ( - key: string, - value: unknown, - actualValue: unknown = value, - ): void => { - const isValidNumber = typeof value === "number" - const isPercentString = typeof value === "string" && value.endsWith("%") - - if (!isValidNumber && !isPercentString) { - errors.push({ - key, - expected: 'number | "${number}%"', - actual: JSON.stringify(actualValue), - }) - } - } - - const validateModelLimits = ( - key: "compress.modelMaxLimits" | "compress.modelMinLimits", - limits: unknown, - ): void => { - if (limits === undefined) { - return - } - - if (typeof limits !== "object" || limits === null || Array.isArray(limits)) { - errors.push({ - key, - expected: "Record", - actual: typeof limits, - }) - return - } - - for (const [providerModelKey, limit] of Object.entries(limits)) { - const isValidNumber = typeof limit === "number" - const isPercentString = - typeof limit === "string" && /^\d+(?:\.\d+)?%$/.test(limit) - if (!isValidNumber && !isPercentString) { - errors.push({ - key: `${key}.${providerModelKey}`, - expected: 'number | "${number}%"', - actual: JSON.stringify(limit), - }) - } - } - } - - if (compress.maxContextLimit !== undefined) { - validateLimitValue("compress.maxContextLimit", compress.maxContextLimit) - } - - if (compress.minContextLimit !== undefined) { - validateLimitValue("compress.minContextLimit", compress.minContextLimit) - } - - validateModelLimits("compress.modelMaxLimits", compress.modelMaxLimits) - validateModelLimits("compress.modelMinLimits", compress.modelMinLimits) - - const validValues = ["ask", "allow", "deny"] - if (compress.permission !== undefined && !validValues.includes(compress.permission)) { - errors.push({ - key: "compress.permission", - expected: '"ask" | "allow" | "deny"', - actual: JSON.stringify(compress.permission), - }) - } - - if ( - compress.showCompression !== undefined && - typeof compress.showCompression !== "boolean" - ) { - errors.push({ - key: "compress.showCompression", - expected: "boolean", - actual: typeof compress.showCompression, - }) - } - } - } + return errors +} - const strategies = config.strategies - if (strategies) { - if ( - strategies.deduplication?.enabled !== undefined && - typeof strategies.deduplication.enabled !== "boolean" - ) { - errors.push({ - key: "strategies.deduplication.enabled", - expected: "boolean", - actual: typeof strategies.deduplication.enabled, - }) - } +export function validateConfigTypes(config: Record): ValidationError[] { + const errors: ValidationError[] = [] - if ( - strategies.deduplication?.protectedTools !== undefined && - !Array.isArray(strategies.deduplication.protectedTools) - ) { - errors.push({ - key: "strategies.deduplication.protectedTools", - expected: "string[]", - actual: typeof strategies.deduplication.protectedTools, - }) - } + for (const [key, rule] of Object.entries(CONFIG_RULES)) { + const value = readConfigPath(config, key) + if (value === undefined) continue - if (strategies.purgeErrors) { - if ( - strategies.purgeErrors.enabled !== undefined && - typeof strategies.purgeErrors.enabled !== "boolean" - ) { - errors.push({ - key: "strategies.purgeErrors.enabled", - expected: "boolean", - actual: typeof strategies.purgeErrors.enabled, - }) - } + const violation = rule.check(value) + if (!violation) continue - if ( - strategies.purgeErrors.turns !== undefined && - typeof strategies.purgeErrors.turns !== "number" - ) { - errors.push({ - key: "strategies.purgeErrors.turns", - expected: "number", - actual: typeof strategies.purgeErrors.turns, - }) - } - // Warn if turns is 0 or negative - will be clamped to 1 - if ( - typeof strategies.purgeErrors.turns === "number" && - strategies.purgeErrors.turns < 1 - ) { - errors.push({ - key: "strategies.purgeErrors.turns", - expected: "positive number (>= 1)", - actual: `${strategies.purgeErrors.turns} (will be clamped to 1)`, - }) - } - if ( - strategies.purgeErrors.protectedTools !== undefined && - !Array.isArray(strategies.purgeErrors.protectedTools) - ) { - errors.push({ - key: "strategies.purgeErrors.protectedTools", - expected: "string[]", - actual: typeof strategies.purgeErrors.protectedTools, - }) - } - } + errors.push( + typeof violation === "string" + ? { key, expected: rule.expected, actual: violation } + : { key, expected: violation.expected, actual: violation.actual }, + ) } - return errors + return errors.concat(validateModelLimitEntries(config)) } function showConfigWarnings( @@ -735,44 +426,28 @@ function findOpencodeDir(startDir: string): string | null { return null } +/** Prefers `dcp.jsonc`, falls back to `dcp.json`; null when the directory has neither. */ +function configInDir(dir: string): string | null { + for (const name of ["dcp.jsonc", "dcp.json"]) { + const candidate = join(dir, name) + if (existsSync(candidate)) return candidate + } + return null +} + function getConfigPaths(ctx?: ConfigContext): { global: string | null configDir: string | null project: string | null } { - const global = existsSync(GLOBAL_CONFIG_PATH_JSONC) - ? GLOBAL_CONFIG_PATH_JSONC - : existsSync(GLOBAL_CONFIG_PATH_JSON) - ? GLOBAL_CONFIG_PATH_JSON - : null - - let configDir: string | null = null const opencodeConfigDir = process.env.OPENCODE_CONFIG_DIR - if (opencodeConfigDir) { - const configJsonc = join(opencodeConfigDir, "dcp.jsonc") - const configJson = join(opencodeConfigDir, "dcp.json") - configDir = existsSync(configJsonc) - ? configJsonc - : existsSync(configJson) - ? configJson - : null - } + const opencodeDir = ctx?.directory ? findOpencodeDir(ctx.directory) : null - let project: string | null = null - if (ctx?.directory) { - const opencodeDir = findOpencodeDir(ctx.directory) - if (opencodeDir) { - const projectJsonc = join(opencodeDir, "dcp.jsonc") - const projectJson = join(opencodeDir, "dcp.json") - project = existsSync(projectJsonc) - ? projectJsonc - : existsSync(projectJson) - ? projectJson - : null - } + return { + global: configInDir(GLOBAL_CONFIG_DIR), + configDir: opencodeConfigDir ? configInDir(opencodeConfigDir) : null, + project: opencodeDir ? configInDir(opencodeDir) : null, } - - return { global, configDir, project } } function createDefaultConfig(): void { @@ -811,154 +486,55 @@ function loadConfigFile(configPath: string): ConfigLoadResult { } } -function mergeStrategies( - base: PluginConfig["strategies"], - override?: Partial, -): PluginConfig["strategies"] { - if (!override) { - return base - } - - return { - deduplication: { - enabled: override.deduplication?.enabled ?? base.deduplication.enabled, - protectedTools: [ - ...new Set([ - ...base.deduplication.protectedTools, - ...(override.deduplication?.protectedTools ?? []), - ]), - ], - }, - purgeErrors: { - enabled: override.purgeErrors?.enabled ?? base.purgeErrors.enabled, - turns: override.purgeErrors?.turns ?? base.purgeErrors.turns, - protectedTools: [ - ...new Set([ - ...base.purgeErrors.protectedTools, - ...(override.purgeErrors?.protectedTools ?? []), - ]), - ], - }, - } -} - -function mergeCompress( - base: PluginConfig["compress"], - override?: CompressOverride, -): PluginConfig["compress"] { - if (!override) { - return base - } - - return { - mode: override.mode ?? base.mode, - permission: override.permission ?? base.permission, - showCompression: override.showCompression ?? base.showCompression, - summaryBuffer: override.summaryBuffer ?? base.summaryBuffer, - maxContextLimit: override.maxContextLimit ?? base.maxContextLimit, - minContextLimit: override.minContextLimit ?? base.minContextLimit, - modelMaxLimits: override.modelMaxLimits ?? base.modelMaxLimits, - modelMinLimits: override.modelMinLimits ?? base.modelMinLimits, - nudgeFrequency: override.nudgeFrequency ?? base.nudgeFrequency, - iterationNudgeThreshold: override.iterationNudgeThreshold ?? base.iterationNudgeThreshold, - nudgeForce: override.nudgeForce ?? base.nudgeForce, - protectedTools: [...new Set([...base.protectedTools, ...(override.protectedTools ?? [])])], - protectTags: override.protectTags ?? base.protectTags, - protectUserMessages: override.protectUserMessages ?? base.protectUserMessages, - } -} - -function mergeCommands( - base: PluginConfig["commands"], - override?: Partial, -): PluginConfig["commands"] { - if (!override) { - return base - } - - return { - enabled: override.enabled ?? base.enabled, - protectedTools: [...new Set([...base.protectedTools, ...(override.protectedTools ?? [])])], - } -} - -function mergeManualMode( - base: PluginConfig["manualMode"], - override?: Partial, -): PluginConfig["manualMode"] { - if (override === undefined) return base - - return { - enabled: override.enabled ?? base.enabled, - automaticStrategies: override.automaticStrategies ?? base.automaticStrategies, - } -} - -function mergeExperimental( - base: PluginConfig["experimental"], - override?: Partial, -): PluginConfig["experimental"] { - if (override === undefined) return base - - return { - allowSubAgents: override.allowSubAgents ?? base.allowSubAgents, - customPrompts: override.customPrompts ?? base.customPrompts, - } -} +/** + * Arrays a user extends rather than replaces: an override appends to the default + * list (deduped) instead of swapping it out. + */ +const UNION_ARRAY_KEYS = new Set([ + "commands.protectedTools", + "compress.protectedTools", + "protectedFilePatterns", + "strategies.deduplication.protectedTools", + "strategies.purgeErrors.protectedTools", +]) -function deepCloneConfig(config: PluginConfig): PluginConfig { - return { - ...config, - commands: { - enabled: config.commands.enabled, - protectedTools: [...config.commands.protectedTools], - }, - manualMode: { - enabled: config.manualMode.enabled, - automaticStrategies: config.manualMode.automaticStrategies, - }, - turnProtection: { ...config.turnProtection }, - experimental: { ...config.experimental }, - protectedFilePatterns: [...config.protectedFilePatterns], - compress: { - ...config.compress, - modelMaxLimits: { ...config.compress.modelMaxLimits }, - modelMinLimits: { ...config.compress.modelMinLimits }, - protectedTools: [...config.compress.protectedTools], - }, - strategies: { - deduplication: { - ...config.strategies.deduplication, - protectedTools: [...config.strategies.deduplication.protectedTools], - }, - purgeErrors: { - ...config.strategies.purgeErrors, - protectedTools: [...config.strategies.purgeErrors.protectedTools], - }, - }, +/** + * Objects replaced wholesale rather than merged key-by-key, so a project config + * cannot leave a single stale model behind in a base config's limits map. + */ +const REPLACE_WHOLE_KEYS = new Set(["compress.modelMaxLimits", "compress.modelMinLimits"]) + +const isPlainObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +/** + * Merges one config layer over the accumulated defaults: absent keys keep their + * default, scalar keys are replaced, `protectedTools`/pattern lists are unioned, + * and the two per-model limit maps are swapped whole. + */ +function mergeConfigLayer>(base: T, override: any, prefix = ""): T { + const merged: Record = { ...base } + + for (const [key, value] of Object.entries(override)) { + if (value === undefined) continue + + const path = prefix ? `${prefix}.${key}` : key + const current = merged[key] + + if (UNION_ARRAY_KEYS.has(path)) { + merged[key] = [...new Set([...((current as string[]) ?? []), ...(value as string[])])] + } else if ( + !REPLACE_WHOLE_KEYS.has(path) && + isPlainObject(value) && + isPlainObject(current) + ) { + merged[key] = mergeConfigLayer(current, value, path) + } else { + merged[key] = value + } } -} -function mergeLayer(config: PluginConfig, data: Record): PluginConfig { - return { - enabled: data.enabled ?? config.enabled, - autoUpdate: data.autoUpdate ?? config.autoUpdate, - debug: data.debug ?? config.debug, - pruneNotification: data.pruneNotification ?? config.pruneNotification, - pruneNotificationType: data.pruneNotificationType ?? config.pruneNotificationType, - commands: mergeCommands(config.commands, data.commands as any), - manualMode: mergeManualMode(config.manualMode, data.manualMode as any), - turnProtection: { - enabled: data.turnProtection?.enabled ?? config.turnProtection.enabled, - turns: data.turnProtection?.turns ?? config.turnProtection.turns, - }, - experimental: mergeExperimental(config.experimental, data.experimental as any), - protectedFilePatterns: [ - ...new Set([...config.protectedFilePatterns, ...(data.protectedFilePatterns ?? [])]), - ], - compress: mergeCompress(config.compress, data.compress as CompressOverride), - strategies: mergeStrategies(config.strategies, data.strategies as any), - } + return merged as T } function scheduleParseWarning(ctx: ConfigContext, title: string, message: string): void { @@ -977,7 +553,7 @@ function scheduleParseWarning(ctx: ConfigContext, title: string, message: string } export function getConfig(ctx: ConfigContext): PluginConfig { - let config = deepCloneConfig(defaultConfig) + let config = structuredClone(defaultConfig) const configPaths = getConfigPaths(ctx) if (!configPaths.global) { @@ -1010,7 +586,7 @@ export function getConfig(ctx: ConfigContext): PluginConfig { } showConfigWarnings(ctx, layer.path, result.data, layer.isProject) - config = mergeLayer(config, result.data) + config = mergeConfigLayer(config, result.data) } return config From d86f610c5f07503670108dc7fce4b0fdc5ae6388 Mon Sep 17 00:00:00 2001 From: amnesiaof Date: Fri, 2 Oct 2026 18:28:06 +0300 Subject: [PATCH 2/5] refactor(state): collapse duplicated hydration and validation loadPruneMessagesState revalidated every field of a file the same module had just written. Shared guards and one block deserializer replace the per field checks. The explicit restore of activeBlockIds and activeByAnchorMessageId is gone. The final loop already repopulates both from block.active, which is where serialization reads them from, so the restore was redundant. loadSessionState validated and deduped three anchor sets with three near identical blocks. One loop over the key names now, with the same log text. PersistedPruneMessagesState was declared verbatim in two files. It lives in types.ts now, which both already import from, avoiding a cycle since persistence.ts imports a function from utils.ts. buildSessionState duplicated the hydration in ensureSessionInitialized. The two had already drifted: the TUI copy never picked up currentTurn or live turn nudge anchors. It calls the shared path now. resetSessionState restated about 25 field assignments from createSessionState. It derives from a fresh state and carries over idFormat, compressionTiming and the two cached maps so external references to them stay valid. --- lib/state/persistence.ts | 81 ++++---------- lib/state/state.ts | 45 +++----- lib/state/types.ts | 10 ++ lib/state/utils.ts | 233 +++++++++++++++------------------------ lib/tui/data.ts | 41 +++---- 5 files changed, 151 insertions(+), 259 deletions(-) diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts index 882e7dad..77e1783d 100644 --- a/lib/state/persistence.ts +++ b/lib/state/persistence.ts @@ -8,20 +8,16 @@ import * as fs from "fs/promises" import { existsSync } from "fs" import { homedir } from "os" import { join } from "path" -import type { CompressionBlock, PrunedMessageEntry, SessionState, SessionStats } from "./types" +import type { + CompressionBlock, + PersistedPruneMessagesState, + PrunedMessageEntry, + SessionState, + SessionStats, +} from "./types" import type { Logger } from "../logger" import { serializePruneMessagesState } from "./utils" -/** Prune state as stored on disk */ -export interface PersistedPruneMessagesState { - byMessageId: Record - blocksById: Record - activeBlockIds: number[] - activeByAnchorMessageId: Record - nextBlockId: number - nextRunId: number -} - export interface PersistedPrune { tools?: Record messages?: PersistedPruneMessagesState @@ -143,53 +139,24 @@ export async function loadSessionState( return null } - const rawContextLimitAnchors = Array.isArray(state.nudges.contextLimitAnchors) - ? state.nudges.contextLimitAnchors - : [] - const validAnchors = rawContextLimitAnchors.filter( - (entry): entry is string => typeof entry === "string", - ) - const dedupedAnchors = [...new Set(validAnchors)] - if (validAnchors.length !== rawContextLimitAnchors.length) { - logger.warn("Filtered out malformed contextLimitAnchors entries", { - sessionId: sessionId, - original: rawContextLimitAnchors.length, - valid: validAnchors.length, - }) - } - state.nudges.contextLimitAnchors = dedupedAnchors - - const rawTurnNudgeAnchors = Array.isArray(state.nudges.turnNudgeAnchors) - ? state.nudges.turnNudgeAnchors - : [] - const validSoftAnchors = rawTurnNudgeAnchors.filter( - (entry): entry is string => typeof entry === "string", - ) - const dedupedSoftAnchors = [...new Set(validSoftAnchors)] - if (validSoftAnchors.length !== rawTurnNudgeAnchors.length) { - logger.warn("Filtered out malformed turnNudgeAnchors entries", { - sessionId: sessionId, - original: rawTurnNudgeAnchors.length, - valid: validSoftAnchors.length, - }) - } - state.nudges.turnNudgeAnchors = dedupedSoftAnchors - - const rawIterationNudgeAnchors = Array.isArray(state.nudges.iterationNudgeAnchors) - ? state.nudges.iterationNudgeAnchors - : [] - const validIterationAnchors = rawIterationNudgeAnchors.filter( - (entry): entry is string => typeof entry === "string", - ) - const dedupedIterationAnchors = [...new Set(validIterationAnchors)] - if (validIterationAnchors.length !== rawIterationNudgeAnchors.length) { - logger.warn("Filtered out malformed iterationNudgeAnchors entries", { - sessionId: sessionId, - original: rawIterationNudgeAnchors.length, - valid: validIterationAnchors.length, - }) + // All three anchor lists share one shape: keep strings, drop duplicates. + for (const key of [ + "contextLimitAnchors", + "turnNudgeAnchors", + "iterationNudgeAnchors", + ] as const) { + const raw = state.nudges[key] + const list = Array.isArray(raw) ? raw : [] + const valid = list.filter((entry): entry is string => typeof entry === "string") + if (valid.length !== list.length) { + logger.warn(`Filtered out malformed ${key} entries`, { + sessionId: sessionId, + original: list.length, + valid: valid.length, + }) + } + state.nudges[key] = [...new Set(valid)] } - state.nudges.iterationNudgeAnchors = dedupedIterationAnchors logger.info("Loaded session state from disk", { sessionId: sessionId, diff --git a/lib/state/state.ts b/lib/state/state.ts index 47ce19af..82463b78 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -104,37 +104,22 @@ export function createSessionState(idFormat: IdFormat = "xml"): SessionState { } } +/** + * Resets every mutable field back to its initial value. The id format and the + * three long-lived collections are carried over rather than replaced, so + * external references to them stay valid. + */ export function resetSessionState(state: SessionState): void { - state.sessionId = null - state.isSubAgent = false - state.manualMode = false - state.compressPermission = undefined - state.pendingManualTrigger = null - state.prune = { - tools: new Map(), - messages: createPruneMessagesState(), - } - state.nudges = { - contextLimitAnchors: new Set(), - turnNudgeAnchors: new Set(), - iterationNudgeAnchors: new Set(), - } - state.stats = { - pruneTokenCounter: 0, - totalPruneTokens: 0, - } - state.toolParameters.clear() - state.subAgentResultCache.clear() - state.toolIdList = [] - state.messageIds = { - byRawId: new Map(), - byRef: new Map(), - nextRef: 1, - } - state.lastCompaction = 0 - state.currentTurn = 0 - state.modelContextLimit = undefined - state.systemPromptTokens = undefined + const { idFormat, toolParameters, subAgentResultCache, compressionTiming } = state + const fresh = createSessionState(idFormat) + + toolParameters.clear() + subAgentResultCache.clear() + fresh.toolParameters = toolParameters + fresh.subAgentResultCache = subAgentResultCache + fresh.compressionTiming = compressionTiming + + Object.assign(state, fresh) } export async function ensureSessionInitialized( diff --git a/lib/state/types.ts b/lib/state/types.ts index 6ac72dc1..2f3f8990 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -92,6 +92,16 @@ export interface Nudges { iterationNudgeAnchors: Set } +/** Prune state as stored on disk */ +export interface PersistedPruneMessagesState { + byMessageId: Record + blocksById: Record + activeBlockIds: number[] + activeByAnchorMessageId: Record + nextBlockId: number + nextRunId: number +} + export interface SessionState { idFormat: IdFormat sessionId: string | null diff --git a/lib/state/utils.ts b/lib/state/utils.ts index f0caf267..b1fb15ca 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -1,5 +1,6 @@ import type { CompressionBlock, + PersistedPruneMessagesState, PruneMessagesState, PrunedMessageEntry, SessionState, @@ -24,15 +25,6 @@ export const isMessageCompacted = (state: SessionState, msg: WithParts): boolean return false } -interface PersistedPruneMessagesState { - byMessageId: Record - blocksById: Record - activeBlockIds: number[] - activeByAnchorMessageId: Record - nextBlockId: number - nextRunId: number -} - export function serializePruneMessagesState( messagesState: PruneMessagesState, ): PersistedPruneMessagesState { @@ -115,161 +107,108 @@ export function createPruneMessagesState(): PruneMessagesState { } } +/** Guards for untrusted on-disk state. A malformed value falls back to the default. */ +const isPosInt = (value: unknown): value is number => + typeof value === "number" && Number.isInteger(value) && value > 0 + +const isNonNegNum = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value) && value >= 0 + +const isStr = (value: unknown): value is string => typeof value === "string" + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +/** Filter to positive integers, order preserved, duplicates dropped. */ +const posIntArray = (value: unknown): number[] => + Array.isArray(value) ? [...new Set(value.filter(isPosInt))] : [] + +/** Filter to strings, order preserved, duplicates dropped. */ +const strArray = (value: unknown): string[] => + Array.isArray(value) ? [...new Set(value.filter(isStr))] : [] + +function deserializeBlock(blockId: number, block: CompressionBlock): CompressionBlock { + return { + blockId, + runId: isPosInt(block.runId) ? block.runId : blockId, + active: block.active === true, + deactivatedByUser: block.deactivatedByUser === true, + compressedTokens: isNonNegNum(block.compressedTokens) ? block.compressedTokens : 0, + summaryTokens: isNonNegNum(block.summaryTokens) + ? block.summaryTokens + : isStr(block.summary) + ? countTokens(block.summary) + : 0, + durationMs: isNonNegNum(block.durationMs) ? block.durationMs : 0, + mode: block.mode === "range" || block.mode === "message" ? block.mode : undefined, + topic: isStr(block.topic) ? block.topic : "", + batchTopic: isStr(block.batchTopic) ? block.batchTopic : (block.topic ?? ""), + startId: isStr(block.startId) ? block.startId : "", + endId: isStr(block.endId) ? block.endId : "", + anchorMessageId: isStr(block.anchorMessageId) ? block.anchorMessageId : "", + compressMessageId: isStr(block.compressMessageId) ? block.compressMessageId : "", + compressCallId: isStr(block.compressCallId) ? block.compressCallId : undefined, + includedBlockIds: posIntArray(block.includedBlockIds), + consumedBlockIds: posIntArray(block.consumedBlockIds), + parentBlockIds: posIntArray(block.parentBlockIds), + directMessageIds: strArray(block.directMessageIds), + directToolIds: strArray(block.directToolIds), + effectiveMessageIds: strArray(block.effectiveMessageIds), + effectiveToolIds: strArray(block.effectiveToolIds), + createdAt: typeof block.createdAt === "number" ? block.createdAt : 0, + deactivatedAt: typeof block.deactivatedAt === "number" ? block.deactivatedAt : undefined, + deactivatedByBlockId: isPosInt(block.deactivatedByBlockId) + ? block.deactivatedByBlockId + : undefined, + summary: isStr(block.summary) ? block.summary : "", + } +} + export function loadPruneMessagesState( persisted?: PersistedPruneMessagesState, ): PruneMessagesState { const state = createPruneMessagesState() - if (!persisted || typeof persisted !== "object") { + if (!isRecord(persisted)) { return state } - if (typeof persisted.nextBlockId === "number" && Number.isInteger(persisted.nextBlockId)) { - state.nextBlockId = Math.max(1, persisted.nextBlockId) - } - if (typeof persisted.nextRunId === "number" && Number.isInteger(persisted.nextRunId)) { - state.nextRunId = Math.max(1, persisted.nextRunId) - } - - if (persisted.byMessageId && typeof persisted.byMessageId === "object") { - for (const [messageId, entry] of Object.entries(persisted.byMessageId)) { - if (!entry || typeof entry !== "object") { - continue - } - - const tokenCount = typeof entry.tokenCount === "number" ? entry.tokenCount : 0 - const allBlockIds = Array.isArray(entry.allBlockIds) - ? [ - ...new Set( - entry.allBlockIds.filter( - (id): id is number => Number.isInteger(id) && id > 0, - ), - ), - ] - : [] - const activeBlockIds = Array.isArray(entry.activeBlockIds) - ? [ - ...new Set( - entry.activeBlockIds.filter( - (id): id is number => Number.isInteger(id) && id > 0, - ), - ), - ] - : [] - - state.byMessageId.set(messageId, { - tokenCount, - allBlockIds, - activeBlockIds, - }) + for (const [messageId, entry] of Object.entries( + isRecord(persisted.byMessageId) ? persisted.byMessageId : {}, + )) { + if (!isRecord(entry)) { + continue } - } - - if (persisted.blocksById && typeof persisted.blocksById === "object") { - for (const [blockIdStr, block] of Object.entries(persisted.blocksById)) { - const blockId = Number.parseInt(blockIdStr, 10) - if (!Number.isInteger(blockId) || blockId < 1 || !block || typeof block !== "object") { - continue - } - const toNumberArray = (value: unknown): number[] => - Array.isArray(value) - ? [ - ...new Set( - value.filter( - (item): item is number => Number.isInteger(item) && item > 0, - ), - ), - ] - : [] - const toStringArray = (value: unknown): string[] => - Array.isArray(value) - ? [...new Set(value.filter((item): item is string => typeof item === "string"))] - : [] - - state.blocksById.set(blockId, { - blockId, - runId: - typeof block.runId === "number" && - Number.isInteger(block.runId) && - block.runId > 0 - ? block.runId - : blockId, - active: block.active === true, - deactivatedByUser: block.deactivatedByUser === true, - compressedTokens: - typeof block.compressedTokens === "number" && - Number.isFinite(block.compressedTokens) - ? Math.max(0, block.compressedTokens) - : 0, - summaryTokens: - typeof block.summaryTokens === "number" && Number.isFinite(block.summaryTokens) - ? Math.max(0, block.summaryTokens) - : typeof block.summary === "string" - ? countTokens(block.summary) - : 0, - durationMs: - typeof block.durationMs === "number" && Number.isFinite(block.durationMs) - ? Math.max(0, block.durationMs) - : 0, - mode: block.mode === "range" || block.mode === "message" ? block.mode : undefined, - topic: typeof block.topic === "string" ? block.topic : "", - batchTopic: - typeof block.batchTopic === "string" - ? block.batchTopic - : typeof block.topic === "string" - ? block.topic - : "", - startId: typeof block.startId === "string" ? block.startId : "", - endId: typeof block.endId === "string" ? block.endId : "", - anchorMessageId: - typeof block.anchorMessageId === "string" ? block.anchorMessageId : "", - compressMessageId: - typeof block.compressMessageId === "string" ? block.compressMessageId : "", - compressCallId: - typeof block.compressCallId === "string" ? block.compressCallId : undefined, - includedBlockIds: toNumberArray(block.includedBlockIds), - consumedBlockIds: toNumberArray(block.consumedBlockIds), - parentBlockIds: toNumberArray(block.parentBlockIds), - directMessageIds: toStringArray(block.directMessageIds), - directToolIds: toStringArray(block.directToolIds), - effectiveMessageIds: toStringArray(block.effectiveMessageIds), - effectiveToolIds: toStringArray(block.effectiveToolIds), - createdAt: typeof block.createdAt === "number" ? block.createdAt : 0, - deactivatedAt: - typeof block.deactivatedAt === "number" ? block.deactivatedAt : undefined, - deactivatedByBlockId: - typeof block.deactivatedByBlockId === "number" && - Number.isInteger(block.deactivatedByBlockId) - ? block.deactivatedByBlockId - : undefined, - summary: typeof block.summary === "string" ? block.summary : "", - }) - } + state.byMessageId.set(messageId, { + tokenCount: typeof entry.tokenCount === "number" ? entry.tokenCount : 0, + allBlockIds: posIntArray(entry.allBlockIds), + activeBlockIds: posIntArray(entry.activeBlockIds), + }) } - if (Array.isArray(persisted.activeBlockIds)) { - for (const blockId of persisted.activeBlockIds) { - if (!Number.isInteger(blockId) || blockId < 1) { - continue - } - state.activeBlockIds.add(blockId) + for (const [blockIdStr, block] of Object.entries( + isRecord(persisted.blocksById) ? persisted.blocksById : {}, + )) { + const blockId = Number.parseInt(blockIdStr, 10) + if (!isPosInt(blockId) || !isRecord(block)) { + continue } + + state.blocksById.set(blockId, deserializeBlock(blockId, block as CompressionBlock)) } - if ( - persisted.activeByAnchorMessageId && - typeof persisted.activeByAnchorMessageId === "object" - ) { - for (const [anchorMessageId, blockId] of Object.entries( - persisted.activeByAnchorMessageId, - )) { - if (typeof blockId !== "number" || !Number.isInteger(blockId) || blockId < 1) { - continue - } - state.activeByAnchorMessageId.set(anchorMessageId, blockId) + for (const [anchorMessageId, blockId] of Object.entries( + isRecord(persisted.activeByAnchorMessageId) ? persisted.activeByAnchorMessageId : {}, + )) { + if (!isPosInt(blockId)) { + continue } + state.activeByAnchorMessageId.set(anchorMessageId, blockId) } + // The block list is authoritative: a block marked active repopulates both indexes. + // Serialization writes those indexes from this same source, so the persisted + // copies of them are redundant. for (const [blockId, block] of state.blocksById) { if (block.active) { state.activeBlockIds.add(blockId) diff --git a/lib/tui/data.ts b/lib/tui/data.ts index f0999717..6f7fffdc 100644 --- a/lib/tui/data.ts +++ b/lib/tui/data.ts @@ -1,9 +1,12 @@ import { getConfig, type PluginConfig } from "../config" import { Logger } from "../logger" import { filterMessages } from "../messages/shape" -import { createSessionState, type SessionState, type WithParts } from "../state" -import { loadSessionState } from "../state/persistence" -import { findLastCompactionTimestamp, loadPruneMap, loadPruneMessagesState } from "../state/utils" +import { + createSessionState, + ensureSessionInitialized, + type SessionState, + type WithParts, +} from "../state" import type { TuiApi } from "./types" export const logger = new Logger(false) @@ -34,32 +37,20 @@ export function sessionMessages(api: TuiApi, sessionID: string): WithParts[] { } export async function buildSessionState( + api: TuiApi, sessionID: string, messages: WithParts[], config: PluginConfig, ): Promise { const state = createSessionState() - state.sessionId = sessionID - state.manualMode = config.manualMode.enabled ? "active" : false - state.lastCompaction = findLastCompactionTimestamp(messages) - - const persisted = await loadSessionState(sessionID, logger) - if (persisted) { - if (typeof persisted.manualMode === "boolean") { - state.manualMode = persisted.manualMode ? "active" : false - } - - state.prune.tools = loadPruneMap(persisted.prune.tools) - state.prune.messages = loadPruneMessagesState(persisted.prune.messages) - state.nudges.contextLimitAnchors = new Set(persisted.nudges.contextLimitAnchors || []) - state.nudges.turnNudgeAnchors = new Set(persisted.nudges.turnNudgeAnchors || []) - state.nudges.iterationNudgeAnchors = new Set(persisted.nudges.iterationNudgeAnchors || []) - state.stats = { - pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0, - totalPruneTokens: persisted.stats?.totalPruneTokens || 0, - } - } - + await ensureSessionInitialized( + api.client, + state, + sessionID, + logger, + messages, + config.manualMode.enabled, + ) return state } @@ -68,6 +59,6 @@ export async function loadSessionData(api: TuiApi, config: PluginConfig) { if (!sessionID) return undefined const messages = sessionMessages(api, sessionID) - const state = await buildSessionState(sessionID, messages, config) + const state = await buildSessionState(api, sessionID, messages, config) return { state, messages } } From 2ee432239d5dc49fa187657ac385e2b517ba2750 Mon Sep 17 00:00:00 2001 From: amnesiaof Date: Fri, 2 Oct 2026 18:28:14 +0300 Subject: [PATCH 3/5] refactor(messages): collapse duplicated prune and inject helpers pruneToolOutputs, pruneToolInputs and pruneToolErrors were three loops differing by a status check, a tool name check and a replacement string. One function takes them as arguments. pruneFullTool is gone. Its only call site was commented out. applyAnchoredNudges made six near identical calls, three per compression mode, differing only in the anchor set and the prompt text. It loops over a table now. injectMessageIds and injectAnchoredNudge shared a seven line tail that placed a synthetic text part before the first tool part. That is prependSyntheticPart now. The two functions stay separate: the user path uses all text parts and the assistant path uses tool parts, so folding them together would need parameterization longer than what it removes. extractParameterKey merged the glob and grep branches, and the websearch and codesearch branches, which had identical bodies and identical fall through. The single field tools now share one lookup. --- lib/messages/inject/inject.ts | 9 +-- lib/messages/inject/utils.ts | 67 ++++++++------------ lib/messages/prune.ts | 113 ++++------------------------------ lib/ui/utils.ts | 47 ++++++-------- 4 files changed, 57 insertions(+), 179 deletions(-) diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index 9d446056..88ff85aa 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -28,6 +28,7 @@ import { getNudgeFrequency, getModelInfo, isContextOverLimits, + prependSyntheticPart, } from "./utils" export const injectCompressNudges = ( @@ -211,12 +212,6 @@ export const injectMessageIds = ( continue } - const syntheticPart = createSyntheticTextPart(message, tag) - const firstToolIndex = message.parts.findIndex((p) => p.type === "tool") - if (firstToolIndex === -1) { - message.parts.push(syntheticPart) - } else { - message.parts.splice(firstToolIndex, 0, syntheticPart) - } + prependSyntheticPart(message, tag) } } diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index 6d35e4c5..c90f2c04 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -208,6 +208,20 @@ function buildMessagePriorityGuidance( return renderMessagePriorityGuidance(priorityLabel, refs) } +/** + * Attach text no existing part will take: a synthetic part placed before the first + * tool part (so the model reads it as context), or appended when there is no tool. + */ +export function prependSyntheticPart(message: WithParts, text: string): void { + const syntheticPart = createSyntheticTextPart(message, text) + const firstToolIndex = message.parts.findIndex((p) => p.type === "tool") + if (firstToolIndex === -1) { + message.parts.push(syntheticPart) + } else { + message.parts.splice(firstToolIndex, 0, syntheticPart) + } +} + function injectAnchoredNudge(message: WithParts, nudgeText: string): void { if (!nudgeText.trim()) { return @@ -238,13 +252,7 @@ function injectAnchoredNudge(message: WithParts, nudgeText: string): void { } } - const syntheticPart = createSyntheticTextPart(message, nudgeText) - const firstToolIndex = message.parts.findIndex((p) => p.type === "tool") - if (firstToolIndex === -1) { - message.parts.push(syntheticPart) - } else { - message.parts.splice(firstToolIndex, 0, syntheticPart) - } + prependSyntheticPart(message, nudgeText) } function collectAnchoredMessages( @@ -329,46 +337,19 @@ export function applyAnchoredNudges( compressionPriorities?: CompressionPriorityMap, ): void { const turnNudgeAnchors = collectTurnNudgeAnchors(state, config, messages) + const nudgeTargets: Array<[Set, string]> = [ + [state.nudges.contextLimitAnchors, prompts.contextLimitNudge], + [turnNudgeAnchors, prompts.turnNudge], + [state.nudges.iterationNudgeAnchors, prompts.iterationNudge], + ] if (config.compress.mode === "message") { - applyMessageModeAnchoredNudge( - state.nudges.contextLimitAnchors, - messages, - prompts.contextLimitNudge, - compressionPriorities, - ) - applyMessageModeAnchoredNudge( - turnNudgeAnchors, - messages, - prompts.turnNudge, - compressionPriorities, - ) - applyMessageModeAnchoredNudge( - state.nudges.iterationNudgeAnchors, - messages, - prompts.iterationNudge, - compressionPriorities, - ) + for (const [anchors, nudgeText] of nudgeTargets) + applyMessageModeAnchoredNudge(anchors, messages, nudgeText, compressionPriorities) return } const compressedBlockGuidance = buildCompressedBlockGuidance(state) - applyRangeModeAnchoredNudge( - state.nudges.contextLimitAnchors, - messages, - prompts.contextLimitNudge, - compressedBlockGuidance, - ) - applyRangeModeAnchoredNudge( - turnNudgeAnchors, - messages, - prompts.turnNudge, - compressedBlockGuidance, - ) - applyRangeModeAnchoredNudge( - state.nudges.iterationNudgeAnchors, - messages, - prompts.iterationNudge, - compressedBlockGuidance, - ) + for (const [anchors, nudgeText] of nudgeTargets) + applyRangeModeAnchoredNudge(anchors, messages, nudgeText, compressedBlockGuidance) } diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index dc394524..b7b0b7dd 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -20,127 +20,38 @@ export const prune = ( summaryBase?: WithParts, ): void => { filterCompressedRanges(state, logger, config, messages, summaryBase) - // pruneFullTool(state, logger, messages) - pruneToolOutputs(state, logger, messages) - pruneToolInputs(state, logger, messages) - pruneToolErrors(state, logger, messages) + pruneToolState(state, messages) } -const pruneFullTool = (state: SessionState, logger: Logger, messages: WithParts[]): void => { - const messagesToRemove: string[] = [] +/** Tools whose completed output is meaningful enough to keep. */ +const OUTPUT_PRESERVED_TOOLS = new Set(["question", "edit", "write"]) +const pruneToolState = (state: SessionState, messages: WithParts[]): void => { for (const msg of messages) { if (isMessageCompacted(state, msg)) { continue } const parts = Array.isArray(msg.parts) ? msg.parts : [] - const partsToRemove: string[] = [] - for (const part of parts) { if (part.type !== "tool") { continue } - if (!state.prune.tools.has(part.callID)) { continue } - if (part.tool !== "edit" && part.tool !== "write") { - continue - } - - partsToRemove.push(part.callID) - } - - if (partsToRemove.length === 0) { - continue - } - - msg.parts = parts.filter( - (part) => part.type !== "tool" || !partsToRemove.includes(part.callID), - ) - - if (msg.parts.length === 0) { - messagesToRemove.push(msg.info.id) - } - } - - if (messagesToRemove.length > 0) { - const result = messages.filter((msg) => !messagesToRemove.includes(msg.info.id)) - messages.length = 0 - messages.push(...result) - } -} - -const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithParts[]): void => { - for (const msg of messages) { - if (isMessageCompacted(state, msg)) { - continue - } - - const parts = Array.isArray(msg.parts) ? msg.parts : [] - for (const part of parts) { - if (part.type !== "tool") { - continue - } - if (!state.prune.tools.has(part.callID)) { - continue - } - if (part.state.status !== "completed") { - continue - } - if (part.tool === "question" || part.tool === "edit" || part.tool === "write") { - continue - } - - part.state.output = PRUNED_TOOL_OUTPUT_REPLACEMENT - } - } -} - -const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithParts[]): void => { - for (const msg of messages) { - if (isMessageCompacted(state, msg)) { - continue - } - - const parts = Array.isArray(msg.parts) ? msg.parts : [] - for (const part of parts) { - if (part.type !== "tool") { - continue - } - if (!state.prune.tools.has(part.callID)) { - continue - } - if (part.state.status !== "completed") { - continue - } - if (part.tool !== "question") { + if (part.state.status === "completed") { + if (part.tool === "question") { + if (part.state.input?.questions !== undefined) { + part.state.input.questions = PRUNED_QUESTION_INPUT_REPLACEMENT + } + } else if (!OUTPUT_PRESERVED_TOOLS.has(part.tool)) { + part.state.output = PRUNED_TOOL_OUTPUT_REPLACEMENT + } continue } - if (part.state.input?.questions !== undefined) { - part.state.input.questions = PRUNED_QUESTION_INPUT_REPLACEMENT - } - } - } -} - -const pruneToolErrors = (state: SessionState, logger: Logger, messages: WithParts[]): void => { - for (const msg of messages) { - if (isMessageCompacted(state, msg)) { - continue - } - - const parts = Array.isArray(msg.parts) ? msg.parts : [] - for (const part of parts) { - if (part.type !== "tool") { - continue - } - if (!state.prune.tools.has(part.callID)) { - continue - } if (part.state.status !== "error") { continue } diff --git a/lib/ui/utils.ts b/lib/ui/utils.ts index a3de072b..1737348a 100644 --- a/lib/ui/utils.ts +++ b/lib/ui/utils.ts @@ -2,9 +2,23 @@ import { SessionState, ToolParameterEntry, WithParts } from "../state" import { countTokens } from "../token-utils" import { isIgnoredUserMessage } from "../messages/query" +/** Tools whose display label is just one parameter value. */ +const SINGLE_FIELD_LABEL: Record = { + write: "filePath", + edit: "filePath", + multiedit: "filePath", + webfetch: "url", + skill: "name", +} + function extractParameterKey(tool: string, parameters: any): string { if (!parameters) return "" + const labelField = SINGLE_FIELD_LABEL[tool] + if (labelField && parameters[labelField]) { + return parameters[labelField] + } + if (tool === "read" && parameters.filePath) { const offset = parameters.offset const limit = parameters.limit @@ -20,10 +34,6 @@ function extractParameterKey(tool: string, parameters: any): string { return parameters.filePath } - if ((tool === "write" || tool === "edit" || tool === "multiedit") && parameters.filePath) { - return parameters.filePath - } - if (tool === "apply_patch" && typeof parameters.patchText === "string") { const pathRegex = /\*\*\* (?:Add|Delete|Update) File: ([^\n\r]+)/g const paths: string[] = [] @@ -46,20 +56,10 @@ function extractParameterKey(tool: string, parameters: any): string { return parameters.path || "(current directory)" } - if (tool === "glob") { - if (parameters.pattern) { - const pathInfo = parameters.path ? ` in ${parameters.path}` : "" - return `"${parameters.pattern}"${pathInfo}` - } - return "(unknown pattern)" - } - - if (tool === "grep") { - if (parameters.pattern) { - const pathInfo = parameters.path ? ` in ${parameters.path}` : "" - return `"${parameters.pattern}"${pathInfo}` - } - return "(unknown pattern)" + if (tool === "glob" || tool === "grep") { + return parameters.pattern + ? `"${parameters.pattern}"${parameters.path ? ` in ${parameters.path}` : ""}` + : "(unknown pattern)" } if (tool === "bash") { @@ -71,13 +71,7 @@ function extractParameterKey(tool: string, parameters: any): string { } } - if (tool === "webfetch" && parameters.url) { - return parameters.url - } - if (tool === "websearch" && parameters.query) { - return `"${parameters.query}"` - } - if (tool === "codesearch" && parameters.query) { + if ((tool === "websearch" || tool === "codesearch") && parameters.query) { return `"${parameters.query}"` } @@ -91,9 +85,6 @@ function extractParameterKey(tool: string, parameters: any): string { if (tool === "task" && parameters.description) { return parameters.description } - if (tool === "skill" && parameters.name) { - return parameters.name - } if (tool === "lsp") { const op = parameters.operation || "lsp" From 240dab284b65b4ee13e14b072b9b1cabbda211b4 Mon Sep 17 00:00:00 2001 From: amnesiaof Date: Fri, 2 Oct 2026 18:28:21 +0300 Subject: [PATCH 4/5] refactor(prompts): inline the single-string nudge templates iteration-nudge.ts, turn-nudge.ts and context-limit-nudge.ts each held one template string. They are constants in store.ts now. PROMPT_KEYS restated the same seven keys that PROMPT_DEFINITIONS already carries. It derives from the definitions. --- lib/prompts/context-limit-nudge.ts | 18 ------------------ lib/prompts/iteration-nudge.ts | 6 ------ lib/prompts/store.ts | 18 +++++++----------- lib/prompts/turn-nudge.ts | 10 ---------- 4 files changed, 7 insertions(+), 45 deletions(-) delete mode 100644 lib/prompts/context-limit-nudge.ts delete mode 100644 lib/prompts/iteration-nudge.ts delete mode 100644 lib/prompts/turn-nudge.ts diff --git a/lib/prompts/context-limit-nudge.ts b/lib/prompts/context-limit-nudge.ts deleted file mode 100644 index 1b378470..00000000 --- a/lib/prompts/context-limit-nudge.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const CONTEXT_LIMIT_NUDGE = ` -CRITICAL WARNING: MAX CONTEXT LIMIT REACHED - -You are at or beyond the configured max context threshold. This is an emergency context-recovery moment. - -You MUST use the \`compress\` tool now. Do not continue normal exploration until compression is handled. - -If you are in the middle of a critical atomic operation, finish that atomic step first, then compress immediately. - -SELECTION PROCESS -Start from older, resolved history and capture as much stale context as safely possible in one pass. -Avoid the newest active working messages unless it is clearly closed. - -SUMMARY REQUIREMENTS -Your summary MUST cover all essential details from the selected messages so work can continue. -If the compressed range includes user messages, preserve user intent exactly. Prefer direct quotes for short user messages to avoid semantic drift. - -` diff --git a/lib/prompts/iteration-nudge.ts b/lib/prompts/iteration-nudge.ts deleted file mode 100644 index f8e4fa9b..00000000 --- a/lib/prompts/iteration-nudge.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const ITERATION_NUDGE = ` -You've been iterating for a while after the last user message. - -If there is a closed portion that is unlikely to be referenced immediately (for example, finished research before implementation), use the compress tool on it now. - -` diff --git a/lib/prompts/store.ts b/lib/prompts/store.ts index a9213a7e..1cfdce25 100644 --- a/lib/prompts/store.ts +++ b/lib/prompts/store.ts @@ -6,11 +6,14 @@ import { systemPrompt } from "./system" import { rangePrompt } from "./compress-range" import { messagePrompt } from "./compress-message" import type { IdFormat } from "../message-ids" -import { CONTEXT_LIMIT_NUDGE } from "./context-limit-nudge" -import { TURN_NUDGE } from "./turn-nudge" -import { ITERATION_NUDGE } from "./iteration-nudge" import { MANUAL_MODE_SYSTEM_EXTENSION, SUBAGENT_SYSTEM_EXTENSION } from "./extensions/system" +// Bundled nudge prompts ship empty on purpose: they are opt-in and users supply +// the text through the prompt override directories. +const CONTEXT_LIMIT_NUDGE = "\n" +const TURN_NUDGE = "\n" +const ITERATION_NUDGE = "\n" + export type PromptKey = | "system" | "compress-range" @@ -109,14 +112,7 @@ const PROMPT_DEFINITIONS: PromptDefinition[] = [ }, ] -export const PROMPT_KEYS: PromptKey[] = [ - "system", - "compress-range", - "compress-message", - "context-limit-nudge", - "turn-nudge", - "iteration-nudge", -] +export const PROMPT_KEYS: PromptKey[] = PROMPT_DEFINITIONS.map((definition) => definition.key) const HTML_COMMENT_REGEX = //g const LEGACY_INLINE_COMMENT_LINE_REGEX = /^[ \t]*\/\/.*?\/\/[ \t]*$/gm diff --git a/lib/prompts/turn-nudge.ts b/lib/prompts/turn-nudge.ts deleted file mode 100644 index 9f64f108..00000000 --- a/lib/prompts/turn-nudge.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const TURN_NUDGE = ` -Evaluate the conversation for compressible ranges. - -If any messages are cleanly closed and unlikely to be needed again, use the compress tool on them. -If direction has shifted, compress earlier ranges that are now less relevant. - -The goal is to filter noise and distill key information so context accumulation stays under control. -Keep active context uncompressed. - -` From ae1cbb8d183b818b7217b42a677b774807ff039d Mon Sep 17 00:00:00 2001 From: amnesiaof Date: Fri, 2 Oct 2026 18:28:49 +0300 Subject: [PATCH 5/5] chore: remove dead code .npmignore does nothing next to the files array in package.json. When both are present files wins, and the files array is the one that runs. report() in lib/v2/index.ts was a stub the V2 entry point never called on a path that reaches a log. reportToLog does the same write directly. --- .npmignore | 24 ------------------------ lib/v2/index.ts | 16 ++++++++++------ 2 files changed, 10 insertions(+), 30 deletions(-) delete mode 100644 .npmignore diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 1a27ecde..00000000 --- a/.npmignore +++ /dev/null @@ -1,24 +0,0 @@ -# Development files -node_modules/ -logs/ -dist/logs/ -*.log -.DS_Store -tsconfig.json -watch-logs.sh -bun.lock - -# Documentation -ANALYSIS.md -docs/ -notes/ - -# Server source file (server runtime ships dist/) -index.ts - -# Git -.git/ -.gitignore - -# OpenCode -.opencode/ diff --git a/lib/v2/index.ts b/lib/v2/index.ts index d1818b54..91a3b8b0 100644 --- a/lib/v2/index.ts +++ b/lib/v2/index.ts @@ -33,11 +33,11 @@ import { analyzeContextTokens } from "../commands/context" import { buildStatsReport } from "../commands/stats" import { rpc } from "./rpc" -// Extension point for future model-invisible V2 reports. Never use synthetic() -// here: its text would enter the model's context, unlike V1 ignored messages. -export async function report(logger: Logger, text: string, sessionID?: string) { +// The shim below stands in for a V1 SDK client so the shared V1 modules can run +// unchanged. Model-invisible reports go to the log: V2 `synthetic()` would put +// their text into the model's context, unlike V1 ignored messages. +const reportToLog = (logger: Logger, text: string, sessionID?: string) => logger.debug("V2 report (display pending)", { sessionID, text }) -} export async function setup(ctx: Plugin.Context) { const warnings = { @@ -107,11 +107,15 @@ export async function setup(ctx: Plugin.Context) { path: { id: string } body: { parts: Array<{ text: string }> } }) => - report(logger, input.body.parts.map((part) => part.text).join("\n"), input.path.id), + reportToLog( + logger, + input.body.parts.map((part) => part.text).join("\n"), + input.path.id, + ), }, tui: { showToast: async (input: { body: { message: string } }) => - report(logger, input.body.message), + reportToLog(logger, input.body.message), }, }