Skip to content

Commit c843ca4

Browse files
committed
fix: config recovery, lower threshold min, unbundle transformers, error logging
- Config validation failures now use per-field defaults instead of disabling entire plugin (closes #4) - execute_threshold_percentage minimum lowered from 35 to 20 - Invalid config fields surface warnings to user via startup notification - Revert @huggingface/transformers bundling back to external resolution (bundling broke sharp/onnxruntime-node native binary resolution) - Logger now serializes Error objects as message + stack instead of {}
1 parent 45c1131 commit c843ca4

7 files changed

Lines changed: 127 additions & 26 deletions

File tree

CONFIGURATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Higher-tier models with longer cache windows benefit from a longer TTL. Setting
6464
| `cache_ttl` | `string` or `object` | `"5m"` | Time after a response before applying pending ops. String or per-model map. |
6565
| `protected_tags` | `number` (1–100) | `20` | Last N active tags immune from immediate dropping. |
6666
| `nudge_interval_tokens` | `number` | `10000` | Minimum token growth between rolling nudges. |
67-
| `execute_threshold_percentage` | `number` (35–80) or `object` | `65` | Context usage that forces queued ops to execute. Capped at 80% max for cache safety. Supports per-model map. |
67+
| `execute_threshold_percentage` | `number` (20–80) or `object` | `65` | Context usage that forces queued ops to execute. Capped at 80% max for cache safety. Supports per-model map. |
6868
| `auto_drop_tool_age` | `number` | `100` | Auto-drop tool outputs older than N tags during execution. |
6969
| `clear_reasoning_age` | `number` | `50` | Clear thinking/reasoning blocks older than N tags. |
7070
| `iteration_nudge_threshold` | `number` | `15` | Consecutive assistant turns without user input before an iteration nudge. |

bun.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/plugin/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"README.md"
3333
],
3434
"scripts": {
35-
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @opencode-ai/plugin && bun build src/cli/index.ts --outfile dist/cli.js --target node --format esm && tsc --emitDeclarationOnly",
35+
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @opencode-ai/plugin --external @huggingface/transformers && bun build src/cli/index.ts --outfile dist/cli.js --target node --format esm && tsc --emitDeclarationOnly",
3636
"typecheck": "tsc --noEmit && tsc -p tsconfig.scripts.json",
3737
"test": "bun test",
3838
"lint": "biome check .",
@@ -44,14 +44,14 @@
4444
},
4545
"dependencies": {
4646
"@clack/prompts": "^1.1.0",
47+
"@huggingface/transformers": "~3.7.6",
4748
"@opencode-ai/plugin": "^1.2.26",
4849
"@opencode-ai/sdk": "^1.2.26",
4950
"ai-tokenizer": "^1.0.6",
5051
"comment-json": "^4.6.2",
5152
"zod": "^4.1.8"
5253
},
5354
"devDependencies": {
54-
"@huggingface/transformers": "~3.7.6",
5555
"@types/node": "^22.0.0",
5656
"bun-types": "^1.3.10",
5757
"typescript": "^5.8.0",

packages/plugin/src/config/index.ts

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@ function mergeConfigs(
8383
return config;
8484
}
8585

86-
function parsePluginConfig(rawConfig: Record<string, unknown>): MagicContextPluginConfig {
86+
function parsePluginConfig(
87+
rawConfig: Record<string, unknown>,
88+
): MagicContextPluginConfig & { configWarnings?: string[] } {
8789
const parsed = MagicContextConfigSchema.safeParse(rawConfig);
8890
const disabledHooks = Array.isArray(rawConfig.disabled_hooks)
8991
? rawConfig.disabled_hooks.filter((value): value is string => typeof value === "string")
@@ -93,23 +95,68 @@ function parsePluginConfig(rawConfig: Record<string, unknown>): MagicContextPlug
9395
? (rawConfig.command as MagicContextPluginConfig["command"])
9496
: undefined;
9597

96-
if (!parsed.success) {
97-
// Return safe defaults with enabled:false so downstream code has all required fields
98-
// instead of an incomplete cast that would produce undefined for 18+ properties.
99-
const defaults = MagicContextConfigSchema.parse({});
100-
return { ...defaults, enabled: false, disabled_hooks: disabledHooks, command };
98+
if (parsed.success) {
99+
return {
100+
...parsed.data,
101+
disabled_hooks: disabledHooks,
102+
command,
103+
};
101104
}
102105

103-
const config: MagicContextPluginConfig = {
104-
...parsed.data,
105-
disabled_hooks: disabledHooks,
106-
command,
107-
};
106+
// Full parse failed — recover field-by-field using defaults for invalid fields.
107+
// Agent configs (historian, dreamer, sidekick) are dropped on error rather than defaulted
108+
// because wrong model config could run expensive models or fail silently.
109+
const defaults = MagicContextConfigSchema.parse({});
110+
const warnings: string[] = [];
111+
112+
// Build a patched copy of rawConfig, replacing invalid fields with undefined
113+
// so Zod fills in defaults on the second parse.
114+
const errorPaths = new Set<string>();
115+
for (const issue of parsed.error.issues) {
116+
const topKey = issue.path[0];
117+
if (topKey !== undefined) {
118+
errorPaths.add(String(topKey));
119+
}
120+
}
108121

109-
return config;
122+
const patched: Record<string, unknown> = { ...rawConfig };
123+
for (const key of errorPaths) {
124+
const isAgentConfig = key === "historian" || key === "dreamer" || key === "sidekick";
125+
if (isAgentConfig) {
126+
// Drop agent configs entirely on error — don't default them
127+
delete patched[key];
128+
warnings.push(
129+
`"${key}": invalid agent configuration, ignoring. Check your magic-context.jsonc.`,
130+
);
131+
} else {
132+
// Use Zod default for this field
133+
delete patched[key];
134+
const defaultVal = (defaults as unknown as Record<string, unknown>)[key];
135+
warnings.push(
136+
`"${key}": invalid value ${JSON.stringify(rawConfig[key])}, using default ${JSON.stringify(defaultVal)}.`,
137+
);
138+
}
139+
}
140+
141+
const retryParsed = MagicContextConfigSchema.safeParse(patched);
142+
if (retryParsed.success) {
143+
return {
144+
...retryParsed.data,
145+
disabled_hooks: disabledHooks,
146+
command,
147+
configWarnings: warnings,
148+
};
149+
}
150+
151+
// If even the patched version fails (shouldn't happen), fall back to full defaults
152+
// but keep enabled:true — the user intended to use the plugin.
153+
warnings.push("Config recovery failed, using all defaults.");
154+
return { ...defaults, disabled_hooks: disabledHooks, command, configWarnings: warnings };
110155
}
111156

112-
export function loadPluginConfig(directory: string): MagicContextPluginConfig {
157+
export function loadPluginConfig(
158+
directory: string,
159+
): MagicContextPluginConfig & { configWarnings?: string[] } {
113160
const userDetected = detectConfigFile(getUserConfigBasePath());
114161
// Check project root first, then .opencode/ — root takes precedence
115162
const rootDetected = detectConfigFile(join(directory, CONFIG_FILE_BASENAME));
@@ -120,14 +167,27 @@ export function loadPluginConfig(directory: string): MagicContextPluginConfig {
120167
const projectConfig =
121168
projectDetected.format === "none" ? null : loadConfigFile(projectDetected.path);
122169

123-
let config: MagicContextPluginConfig = parsePluginConfig({});
170+
let config: MagicContextPluginConfig & { configWarnings?: string[] } = parsePluginConfig({});
171+
const allWarnings: string[] = [];
124172

125173
if (userConfig) {
126-
config = mergeConfigs(config, parsePluginConfig(userConfig));
174+
const parsed = parsePluginConfig(userConfig);
175+
if (parsed.configWarnings?.length) {
176+
allWarnings.push(...parsed.configWarnings.map((w) => `[user config] ${w}`));
177+
}
178+
config = mergeConfigs(config, parsed);
127179
}
128180

129181
if (projectConfig) {
130-
config = mergeConfigs(config, parsePluginConfig(projectConfig));
182+
const parsed = parsePluginConfig(projectConfig);
183+
if (parsed.configWarnings?.length) {
184+
allWarnings.push(...parsed.configWarnings.map((w) => `[project config] ${w}`));
185+
}
186+
config = mergeConfigs(config, parsed);
187+
}
188+
189+
if (allWarnings.length > 0) {
190+
config.configWarnings = allWarnings;
131191
}
132192

133193
return config;

packages/plugin/src/config/schema/magic-context.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,10 @@ export const MagicContextConfigSchema = z
170170
/** Context percentage that forces queued operations to execute. Number or per-model object ({ default: 65, "provider/model": 45 }). Default: DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE */
171171
execute_threshold_percentage: z
172172
.union([
173-
z.number().min(35).max(95),
173+
z.number().min(20).max(95),
174174
z
175-
.object({ default: z.number().min(35).max(95) })
176-
.catchall(z.number().min(35).max(95)),
175+
.object({ default: z.number().min(20).max(95) })
176+
.catchall(z.number().min(20).max(95)),
177177
])
178178
.default(DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE),
179179
/** Number of recent tags to protect from dropping (min: 1, max: 100, default: 20) */

packages/plugin/src/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,42 @@ import { MagicContextRpcServer } from "./shared/rpc-server";
2626
const plugin: Plugin = async (ctx) => {
2727
const pluginConfig = loadPluginConfig(ctx.directory);
2828

29+
// Surface config validation warnings to user and log
30+
if (pluginConfig.configWarnings?.length) {
31+
for (const w of pluginConfig.configWarnings) {
32+
log(`[magic-context] config warning: ${w}`);
33+
}
34+
// Send warning to user via startup notification (after a short delay so session is ready)
35+
const warningText = [
36+
"## ⚠️ Magic Context Config Warning",
37+
"",
38+
"Some configuration values are invalid and were replaced with defaults:",
39+
"",
40+
...pluginConfig.configWarnings.map((w) => `- ${w}`),
41+
"",
42+
"Check your `magic-context.jsonc` to fix these values.",
43+
].join("\n");
44+
45+
setTimeout(async () => {
46+
try {
47+
const { sendIgnoredMessage } = await import(
48+
"./hooks/magic-context/send-session-notification"
49+
);
50+
// sendIgnoredMessage already handles TUI (toast) vs Desktop (ignored message)
51+
// via isTuiConnected(). We need a session ID — use the first active session.
52+
const sessions = await Promise.resolve((ctx.client as any).session?.list?.()).catch(
53+
() => null,
54+
);
55+
const sessionId = (sessions as any)?.data?.[0]?.id ?? (sessions as any)?.[0]?.id;
56+
if (sessionId) {
57+
await sendIgnoredMessage(ctx.client, sessionId, warningText, {});
58+
}
59+
} catch {
60+
// Intentional: config warning delivery must not crash startup
61+
}
62+
}, 3000);
63+
}
64+
2965
// Detect conflicts that prevent magic-context from operating correctly
3066
let conflictResult: ConflictResult | null = null;
3167
if (pluginConfig.enabled) {

packages/plugin/src/shared/logger.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,12 @@ export function log(message: string, data?: unknown): void {
3737
if (isTestEnv) return;
3838
try {
3939
const timestamp = new Date().toISOString();
40-
const serialized = data === undefined ? "" : ` ${JSON.stringify(data)}`;
40+
const serialized =
41+
data === undefined
42+
? ""
43+
: data instanceof Error
44+
? ` ${data.message}${data.stack ? `\n${data.stack}` : ""}`
45+
: ` ${JSON.stringify(data)}`;
4146
buffer.push(`[${timestamp}] ${message}${serialized}\n`);
4247
if (buffer.length >= BUFFER_SIZE_LIMIT) {
4348
flush();

0 commit comments

Comments
 (0)