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/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 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/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. - -` 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 } } 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" 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), }, }