(
rawConfig: Record<string, unknown>,
recoveredTopLevelKeys: string[] = [],
)
| 214 | } |
| 215 | |
| 216 | function parsePluginConfig( |
| 217 | rawConfig: Record<string, unknown>, |
| 218 | recoveredTopLevelKeys: string[] = [], |
| 219 | ): MagicContextPluginConfig & { configWarnings?: string[] } { |
| 220 | // Pre-Zod shim: reshape legacy experimental.* graduated keys so the user's |
| 221 | // opt-in/out state survives upgrades even when they never run `doctor`. |
| 222 | const preMigrationWarnings: string[] = []; |
| 223 | const migratedExperimental = migrateLegacyExperimental(rawConfig, preMigrationWarnings); |
| 224 | // Dreamer v2: convert the legacy v1 dreamer shape (window schedule, tasks |
| 225 | // array, user_memories/pin_key_files blocks) into the per-task `tasks` record. |
| 226 | // Runs AFTER migrate-experimental so experimental.user_memories (already |
| 227 | // relocated to dreamer.user_memories above) is folded into the v2 tasks here. |
| 228 | const migratedDreamer = migrateDreamerV2(migratedExperimental, preMigrationWarnings); |
| 229 | const migrated = migrateLegacyAgentEnabledInMemory(migratedDreamer, preMigrationWarnings); |
| 230 | const parsed = MagicContextConfigSchema.safeParse(migrated); |
| 231 | const disabledHooks = Array.isArray(rawConfig.disabled_hooks) |
| 232 | ? rawConfig.disabled_hooks.filter((value): value is string => typeof value === "string") |
| 233 | : undefined; |
| 234 | const command = |
| 235 | typeof rawConfig.command === "object" && rawConfig.command !== null |
| 236 | ? (rawConfig.command as MagicContextPluginConfig["command"]) |
| 237 | : undefined; |
| 238 | |
| 239 | if (parsed.success) { |
| 240 | return { |
| 241 | ...parsed.data, |
| 242 | disabled_hooks: disabledHooks, |
| 243 | command, |
| 244 | ...(preMigrationWarnings.length > 0 ? { configWarnings: preMigrationWarnings } : {}), |
| 245 | }; |
| 246 | } |
| 247 | |
| 248 | // Full parse failed — recover field-by-field using defaults for invalid fields. |
| 249 | // Agent configs (historian, dreamer, sidekick) are dropped on error rather than defaulted |
| 250 | // because wrong model config could run expensive models or fail silently. |
| 251 | const defaults = MagicContextConfigSchema.parse({}); |
| 252 | const warnings: string[] = []; |
| 253 | |
| 254 | // Build a patched copy of rawConfig, replacing invalid fields with undefined |
| 255 | // so Zod fills in defaults on the second parse. |
| 256 | const errorPaths = new Set<string>(); |
| 257 | // Collect any custom Zod messages per top-level key so a field with an |
| 258 | // explanatory `.max(..., "why")` / `.refine(..., "why")` message surfaces the |
| 259 | // reason to the user instead of a bare "invalid value" (e.g. issue #111's |
| 260 | // execute_threshold cache-safety explanation). Only non-default Zod messages |
| 261 | // are kept — the generic "Too big"/"Invalid input" boilerplate adds nothing. |
| 262 | const customMessagesByKey = new Map<string, string>(); |
| 263 | // Per top-level key, the set of FULL error paths (e.g. ["memory","auto_search"]). |
| 264 | // Used to prune only the invalid nested leaf instead of the whole block. |
| 265 | const issuePathsByKey = new Map<string, PropertyKey[][]>(); |
| 266 | const GENERIC_ZOD_PREFIXES = ["Too big", "Too small", "Invalid input", "Invalid", "Expected"]; |
| 267 | for (const issue of parsed.error.issues) { |
| 268 | const topKey = issue.path[0]; |
| 269 | if (topKey !== undefined) { |
| 270 | const key = String(topKey); |
| 271 | errorPaths.add(key); |
| 272 | const paths = issuePathsByKey.get(key) ?? []; |
| 273 | paths.push([...issue.path]); |
no test coverage detected