Skip to content

Commit 84bdd1c

Browse files
committed
feat: models.dev context limits, emergency historian, drop_tool_structure, dreamer improvements
- Accurate context limits via models.dev cache (fixes non-Anthropic model usage) - Historian retry with jittered backoff + primary model fallback - Emergency handling: 95% abort, persisted failure state, restart recovery - drop_tool_structure config: per-tag drop_mode, selective input truncation - Doctor cache management and diagnostics - Cross-project embedding sweep - Dreamer prompt improvements for memory pruning - Anti-hallucination prompt covering live tail tool gaps - Web mode /ctx-* command fix (#1), Windows config path fix (#2) - TUI /ctx-status crash fix, 85% no longer blocks - Transform performance: lazy hasEligibleCompartmentHistory - Model-change detection fixes, first-pass stale state reset
1 parent d8fc2b3 commit 84bdd1c

61 files changed

Lines changed: 2504 additions & 200 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
<p align="center">
99
<a href="https://www.npmjs.com/package/@cortexkit/opencode-magic-context"><img src="https://img.shields.io/npm/v/@cortexkit/opencode-magic-context?color=blue&style=flat-square" alt="npm"></a>
10+
<a href="https://www.npmjs.com/package/@cortexkit/opencode-magic-context"><img src="https://img.shields.io/npm/dm/@cortexkit/opencode-magic-context?color=brightgreen&style=flat-square&label=downloads" alt="downloads"></a>
11+
<a href="https://github.com/cortexkit/opencode-magic-context/stargazers"><img src="https://img.shields.io/github/stars/cortexkit/opencode-magic-context?style=flat-square&color=yellow" alt="stars"></a>
12+
<a href="https://github.com/cortexkit/opencode-magic-context/commits"><img src="https://img.shields.io/github/last-commit/cortexkit/opencode-magic-context?style=flat-square&color=green" alt="last commit"></a>
1013
<a href="https://github.com/cortexkit/opencode-magic-context/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="MIT License"></a>
1114
</p>
1215

packages/dashboard/src/components/ConfigEditor/ConfigEditor.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,14 @@ interface FieldDef {
5757
options?: string[];
5858
description: string;
5959
section: string;
60+
defaultValue?: boolean | number | string;
6061
}
6162

6263
const FIELD_DEFS: FieldDef[] = [
6364
// General
6465
{ key: "enabled", label: "Enabled", type: "boolean", description: "Enable the magic-context plugin", section: "General" },
6566
{ key: "ctx_reduce_enabled", label: "Agent Controlled Reduction", type: "boolean", description: "Enable agent controlled reductions via ctx_reduce tool. When enabled, agent is prompted and nudged to choose what messages and tool calls to drop periodically. If disabled the system still works via auto drops based on message ages.", section: "General" },
67+
{ key: "drop_tool_structure", label: "Drop Tool Structure", type: "boolean", description: "When enabled, dropped tool calls are fully removed. When disabled, tool input/output is truncated in place so tool structure stays visible.", section: "General", defaultValue: false },
6668
// Thresholds
6769
// cache_ttl and execute_threshold_percentage are rendered as custom PerModelField components
6870
{ key: "nudge_interval_tokens", label: "Nudge Interval (tokens)", type: "number", description: "Token interval between rolling ctx_reduce nudges.", section: "General" },
@@ -245,6 +247,11 @@ function ConfigForm(props: {
245247
const v = value();
246248
return v != null && typeof v === "object" && !Array.isArray(v);
247249
};
250+
const booleanValue = () => {
251+
const v = value();
252+
if (typeof v === "boolean") return v;
253+
return (field.defaultValue as boolean | undefined) ?? true;
254+
};
248255
const isRangeSlider = field.type === "number" && RANGE_SLIDER_FIELDS.has(field.key) && !isObjectValue();
249256

250257
return (
@@ -256,9 +263,9 @@ function ConfigForm(props: {
256263
<span class="config-field-desc">{field.description}</span>
257264
{field.type === "boolean" ? (
258265
<label class="toggle-switch">
259-
<input type="checkbox" checked={value() as boolean ?? true} onChange={(e) => handleFieldChange(field.key, e.currentTarget.checked)} />
266+
<input type="checkbox" checked={booleanValue()} onChange={(e) => handleFieldChange(field.key, e.currentTarget.checked)} />
260267
<span class="toggle-slider" />
261-
<span class="toggle-label">{(value() as boolean ?? true) ? "Enabled" : "Disabled"}</span>
268+
<span class="toggle-label">{booleanValue() ? "Enabled" : "Disabled"}</span>
262269
</label>
263270
) : field.type === "select" ? (
264271
<select class="config-input" value={String(value() ?? "")} onChange={(e) => handleFieldChange(field.key, e.currentTarget.value)}>

packages/plugin/src/agents/magic-context-prompt.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,17 @@ type AgentType =
1212
| "athena"
1313
| "athena-junior";
1414

15+
function getToolHistoryGuidance(dropToolStructure: boolean): string {
16+
if (dropToolStructure) {
17+
return `Compressed history intentionally omits tool calls and their outputs — summaries like "I edited file X" are historian records, not patterns to replicate. In the live conversation, older tool calls and their results are cleaned up to save context — you may see your own past messages referencing actions without the corresponding tool call or result visible. This is normal context management. ALWAYS use real tool calls; never simulate, fabricate, or inline tool outputs in your text. If there is no tool result message, the action did not happen. NEVER simulate, hallucinate or claim tool calls, command output, search results, file edits, or diffs in plain text as if they actually occurred.`;
18+
}
19+
20+
return `Older tool calls in your conversation show truncated inputs and [truncated] outputs — this is normal context management, not a pattern to follow. The original tool calls executed successfully with full inputs and produced real outputs that were later cleaned up to save context. ALWAYS use real tool calls with complete arguments; never copy truncated patterns like "filePa...[truncated]" into your tool inputs. If you need to re-read a file or re-run a command, make a fresh tool call.`;
21+
}
22+
1523
const BASE_INTRO = (
1624
protectedTags: number,
25+
dropToolStructure: boolean,
1726
): string => `Messages and tool outputs are tagged with §N§ identifiers (e.g., §1§, §42§).
1827
Use \`ctx_reduce\` to manage context size. It supports one operation:
1928
- \`drop\`: Remove entirely (best for tool outputs you already acted on).
@@ -33,13 +42,16 @@ Use \`ctx_expand\` to decompress a compartment range to see the original convers
3342
- Looking for how something was implemented previously → \`ctx_search(query="how does the dreamer lease work")\`
3443
- Want to recall what was decided in an earlier conversation → \`ctx_search(query="dashboard release signing setup")\`
3544
\`ctx_search\` returns ranked results from memories, session facts, and raw message history. Use message ordinals from results with \`ctx_expand\` to retrieve surrounding conversation context.
45+
${getToolHistoryGuidance(dropToolStructure)}
3646
NEVER drop large ranges blindly (e.g., "1-50"). Review each tag before deciding.
3747
NEVER drop user messages — they are short and will be summarized by compartmentalization automatically. Dropping them loses context the historian needs.
3848
NEVER drop assistant text messages unless they are exceptionally large. Your conversation messages are lightweight; only large tool outputs are worth dropping.
3949
Before your turn finishes, consider using \`ctx_reduce\` to drop large tool outputs you no longer need.`;
4050

4151
/** Intro when ctx_reduce is disabled — no drop guidance, no ctx_reduce references. */
42-
const BASE_INTRO_NO_REDUCE = `Messages and tool outputs are tagged with §N§ identifiers (e.g., §1§, §42§).
52+
const BASE_INTRO_NO_REDUCE = (
53+
dropToolStructure: boolean,
54+
): string => `Messages and tool outputs are tagged with §N§ identifiers (e.g., §1§, §42§).
4355
Use \`ctx_note\` for deferred intentions — things to tackle later, not right now. NOT for task tracking (use todos). Notes survive context compression and you'll be reminded at natural work boundaries (after commits, historian runs, todo completion).
4456
Use \`ctx_memory\` to manage cross-session project memories. Write new memories or delete stale ones. Memories persist across sessions and are automatically injected into new sessions.
4557
**Save to memory proactively**: If you spent multiple turns finding something (a file path, a DB location, a config pattern, a workaround), save it with \`ctx_memory\` so future sessions don't repeat the search. Examples:
@@ -54,7 +66,8 @@ Use \`ctx_expand\` to decompress a compartment range to see the original convers
5466
- Need a config value, API key location, or environment detail → \`ctx_search(query="embedding provider configuration")\`
5567
- Looking for how something was implemented previously → \`ctx_search(query="how does the dreamer lease work")\`
5668
- Want to recall what was decided in an earlier conversation → \`ctx_search(query="dashboard release signing setup")\`
57-
\`ctx_search\` returns ranked results from memories, session facts, and raw message history. Use message ordinals from results with \`ctx_expand\` to retrieve surrounding conversation context.`;
69+
\`ctx_search\` returns ranked results from memories, session facts, and raw message history. Use message ordinals from results with \`ctx_expand\` to retrieve surrounding conversation context.
70+
${getToolHistoryGuidance(dropToolStructure)}`;
5871

5972
const SISYPHUS_SECTION = `
6073
### Reduction Triggers
@@ -217,14 +230,15 @@ export function buildMagicContextSection(
217230
protectedTags: number,
218231
ctxReduceEnabled = true,
219232
dreamerEnabled = false,
233+
dropToolStructure = true,
220234
): string {
221235
const smartNoteGuidance = dreamerEnabled
222236
? `\nWhen \`surface_condition\` is provided with \`write\`, the note becomes a project-scoped smart note.\nThe dreamer evaluates smart note conditions during nightly runs and surfaces them when conditions are met.\nExample: \`ctx_note(action="write", content="Implement X because Y", surface_condition="When PR #42 is merged in this repo")\``
223237
: "";
224238

225239
if (!ctxReduceEnabled) {
226-
return `## Magic Context\n\n${BASE_INTRO_NO_REDUCE}${smartNoteGuidance}`;
240+
return `## Magic Context\n\n${BASE_INTRO_NO_REDUCE(dropToolStructure)}${smartNoteGuidance}`;
227241
}
228242
const section = agent ? AGENT_SECTIONS[agent] : GENERIC_SECTION;
229-
return `## Magic Context\n\n${BASE_INTRO(protectedTags)}${smartNoteGuidance}\n${section}\n\nPrefer many small targeted operations over one large blanket operation. Compress early and often — don't wait for warnings.`;
243+
return `## Magic Context\n\n${BASE_INTRO(protectedTags, dropToolStructure)}${smartNoteGuidance}\n${section}\n\nPrefer many small targeted operations over one large blanket operation. Compress early and often — don't wait for warnings.`;
230244
}

packages/plugin/src/cli/config-paths.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ function getConfigDir(): string {
1616
const envDir = process.env.OPENCODE_CONFIG_DIR?.trim();
1717
if (envDir) return envDir;
1818

19+
// OpenCode uses ~/.config/opencode on all platforms (including Windows),
20+
// not %APPDATA%. Match the runtime plugin's path resolution.
1921
if (process.platform === "win32") {
20-
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
21-
return join(appData, "opencode");
22+
return join(homedir(), ".config", "opencode");
2223
}
2324

2425
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");

packages/plugin/src/cli/doctor.ts

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
1+
import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2+
import { createRequire } from "node:module";
3+
import { homedir, platform, tmpdir } from "node:os";
4+
import { join } from "node:path";
25
import { parse, stringify } from "comment-json";
36
import { detectConflicts } from "../shared/conflict-detector";
47
import { fixConflicts } from "../shared/conflict-fixer";
@@ -10,6 +13,98 @@ import { intro, log, outro } from "./prompts";
1013
const PLUGIN_NAME = "@cortexkit/opencode-magic-context";
1114
const PLUGIN_ENTRY_WITH_VERSION = `${PLUGIN_NAME}@latest`;
1215

16+
/**
17+
* Resolve OpenCode's XDG-based cache directory.
18+
* OpenCode uses `xdg-basedir` which resolves to:
19+
* - macOS/Linux: $XDG_CACHE_HOME or ~/.cache
20+
* - Windows: $XDG_CACHE_HOME or %LOCALAPPDATA%
21+
* Plugin cache lives at <cacheDir>/opencode/packages/<pkg>/
22+
*/
23+
function getOpenCodeCacheDir(): string {
24+
const xdgCache = process.env.XDG_CACHE_HOME;
25+
if (xdgCache) return join(xdgCache, "opencode");
26+
27+
const os = platform();
28+
if (os === "win32") {
29+
const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local");
30+
return join(localAppData, "opencode");
31+
}
32+
// macOS + Linux
33+
return join(homedir(), ".cache", "opencode");
34+
}
35+
36+
async function clearPluginCache(): Promise<{
37+
action: "cleared" | "up_to_date" | "not_found" | "error";
38+
path: string;
39+
cached?: string;
40+
latest?: string;
41+
error?: string;
42+
}> {
43+
const cacheDir = getOpenCodeCacheDir();
44+
const pluginCacheDir = join(cacheDir, "packages", PLUGIN_ENTRY_WITH_VERSION);
45+
46+
if (!existsSync(pluginCacheDir)) {
47+
return { action: "not_found", path: pluginCacheDir };
48+
}
49+
50+
// Read cached version from the installed package.json (more reliable than package-lock.json)
51+
let cachedVersion: string | undefined;
52+
try {
53+
const installedPkgPath = join(
54+
pluginCacheDir,
55+
"node_modules",
56+
"@cortexkit",
57+
"opencode-magic-context",
58+
"package.json",
59+
);
60+
if (existsSync(installedPkgPath)) {
61+
const pkg = JSON.parse(readFileSync(installedPkgPath, "utf-8"));
62+
if (typeof pkg?.version === "string") {
63+
cachedVersion = pkg.version;
64+
}
65+
}
66+
} catch {
67+
// Can't read cached version — proceed with clearing
68+
}
69+
70+
// Compare against our own version — when running via `bunx @cortexkit/opencode-magic-context@latest doctor`,
71+
// our package.json IS the latest published version. No network call needed.
72+
// Try multiple relative paths to handle both src/ and dist/ build output locations.
73+
const require = createRequire(import.meta.url);
74+
let selfVersion: string | undefined;
75+
for (const relPath of ["../../package.json", "../package.json"]) {
76+
try {
77+
selfVersion = (require(relPath) as { version?: string }).version;
78+
if (selfVersion) break;
79+
} catch {
80+
// Try next path
81+
}
82+
}
83+
84+
// If we know both versions and they match, skip
85+
if (cachedVersion && cachedVersion === selfVersion) {
86+
return {
87+
action: "up_to_date",
88+
path: pluginCacheDir,
89+
cached: cachedVersion,
90+
latest: selfVersion,
91+
};
92+
}
93+
94+
try {
95+
rmSync(pluginCacheDir, { recursive: true, force: true });
96+
return {
97+
action: "cleared",
98+
path: pluginCacheDir,
99+
cached: cachedVersion,
100+
latest: selfVersion,
101+
};
102+
} catch (err: unknown) {
103+
const msg = err instanceof Error ? err.message : String(err);
104+
return { action: "error", path: pluginCacheDir, error: msg };
105+
}
106+
}
107+
13108
export async function runDoctor(): Promise<number> {
14109
intro("Magic Context Doctor");
15110

@@ -133,7 +228,64 @@ export async function runDoctor(): Promise<number> {
133228
}
134229
}
135230

136-
// 8. Check OMO config
231+
// 8. Check plugin npm cache — clear only if outdated
232+
const cacheResult = await clearPluginCache();
233+
if (cacheResult.action === "cleared") {
234+
const versionInfo = cacheResult.cached
235+
? ` (cached: ${cacheResult.cached}${cacheResult.latest ? `, latest: ${cacheResult.latest}` : ""})`
236+
: "";
237+
log.success(
238+
`Cleared outdated plugin cache${versionInfo} — latest will download on restart`,
239+
);
240+
log.info(` ${cacheResult.path}`);
241+
fixed++;
242+
} else if (cacheResult.action === "up_to_date") {
243+
log.success(`Plugin cache up to date (v${cacheResult.cached})`);
244+
} else if (cacheResult.action === "error") {
245+
log.warn(`Could not clear plugin cache: ${cacheResult.error}`);
246+
log.info(` Manually delete: ${cacheResult.path}`);
247+
issues++;
248+
} else {
249+
log.success("Plugin cache clean (no cached version found)");
250+
}
251+
252+
// 9. Show diagnostics info (log file, historian dumps)
253+
const logPath = join(tmpdir(), "magic-context.log");
254+
if (existsSync(logPath)) {
255+
const logStat = statSync(logPath);
256+
const sizeKb = (logStat.size / 1024).toFixed(0);
257+
log.info(`Log file: ${logPath} (${sizeKb} KB)`);
258+
} else {
259+
log.info(`Log file: ${logPath} (not yet created)`);
260+
}
261+
262+
const historianDumpDir = join(tmpdir(), "magic-context-historian");
263+
if (existsSync(historianDumpDir)) {
264+
try {
265+
const dumps = readdirSync(historianDumpDir)
266+
.filter((f) => f.endsWith(".xml"))
267+
.map((f) => ({
268+
name: f,
269+
mtime: statSync(join(historianDumpDir, f)).mtimeMs,
270+
}))
271+
.sort((a, b) => b.mtime - a.mtime);
272+
if (dumps.length > 0) {
273+
log.warn(`Historian debug dumps: ${dumps.length} file(s) in ${historianDumpDir}`);
274+
for (const dump of dumps.slice(0, 3)) {
275+
const age = Math.round((Date.now() - dump.mtime) / 60000);
276+
const ageStr = age < 60 ? `${age}m ago` : `${Math.round(age / 60)}h ago`;
277+
log.info(` ${dump.name} (${ageStr})`);
278+
}
279+
if (dumps.length > 3) {
280+
log.info(` ... and ${dumps.length - 3} more`);
281+
}
282+
}
283+
} catch {
284+
// Can't read dump directory — skip
285+
}
286+
}
287+
288+
// 10. Check OMO config
137289
if (paths.omoConfig) {
138290
log.info(`OMO config found: ${paths.omoConfig}`);
139291
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ describe("MagicContextConfigSchema", () => {
2121
execute_threshold_percentage: 65,
2222
protected_tags: 20,
2323
auto_drop_tool_age: 100,
24+
drop_tool_structure: true,
2425
clear_reasoning_age: 50,
2526
iteration_nudge_threshold: 15,
2627
compartment_token_budget: DEFAULT_COMPARTMENT_TOKEN_BUDGET,
@@ -53,6 +54,7 @@ describe("MagicContextConfigSchema", () => {
5354
nudge_interval_tokens: 15_000,
5455
execute_threshold_percentage: 75,
5556
auto_drop_tool_age: 150,
57+
drop_tool_structure: false,
5658
clear_reasoning_age: 60,
5759
iteration_nudge_threshold: 20,
5860
compartment_token_budget: 25_000,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export interface MagicContextConfig {
115115
execute_threshold_percentage: number | { default: number; [modelKey: string]: number };
116116
protected_tags: number;
117117
auto_drop_tool_age: number;
118+
drop_tool_structure: boolean;
118119
clear_reasoning_age: number;
119120
iteration_nudge_threshold: number;
120121
compartment_token_budget: number;
@@ -179,6 +180,8 @@ export const MagicContextConfigSchema = z
179180
protected_tags: z.number().min(1).max(100).optional(),
180181
/** Auto-drop tool outputs older than N tags during queue execution (default: 100) */
181182
auto_drop_tool_age: z.number().min(10).default(100),
183+
/** When true, dropped tool parts are fully removed instead of truncated in place (default: true) */
184+
drop_tool_structure: z.boolean().default(true),
182185
/** Clear reasoning/thinking blocks older than N tags (default: 50) */
183186
clear_reasoning_age: z.number().min(10).default(50),
184187
/** Number of consecutive assistant messages without user input to trigger iteration nudge (default: 15) */

0 commit comments

Comments
 (0)