Skip to content

Commit 104329f

Browse files
committed
feat: add ctx_reduce_enabled config flag and independent dreamer timer
ctx_reduce_enabled (default: true) fully disables the ctx_reduce tool, all nudges (rolling, iteration, emergency 80%), sticky turn reminders, and prompt guidance about ctx_reduce when set to false. Heuristic cleanup, compartments, memory, and other features remain active. Surfaces gated: tool-registry, magic-context-prompt, nudger (no-op), hook-handlers (emergency + sticky), system-prompt-hash (prompt injection). Also: dreamer schedule checks now run on an independent 15-minute setInterval at the plugin level (src/plugin/dream-timer.ts) instead of piggybacking on message.updated events, so overnight dreaming triggers even when the user isn't chatting.
1 parent dc18772 commit 104329f

File tree

9 files changed

+138
-24
lines changed

9 files changed

+138
-24
lines changed

src/agents/magic-context-prompt.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ NEVER drop user messages — they are short and will be summarized by compartmen
2626
NEVER drop assistant text messages unless they are exceptionally large. Your conversation messages are lightweight; only large tool outputs are worth dropping.
2727
Before your turn finishes, consider using \`ctx_reduce\` to drop large tool outputs you no longer need.`;
2828

29+
/** Intro when ctx_reduce is disabled — no drop guidance, no ctx_reduce references. */
30+
const BASE_INTRO_NO_REDUCE = `Messages and tool outputs are tagged with §N§ identifiers (e.g., §1§, §42§).
31+
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).
32+
Use \`ctx_memory\` to manage cross-session project memories. Write new memories, delete stale ones, or search stored memories by category. Memories persist across sessions and are automatically injected into new sessions.
33+
Use \`ctx_expand\` to decompress a compartment range to see the original conversation transcript. Use \`start\`/\`end\` from \`<compartment start=N end=M>\` attributes. Returns the compacted U:/A: transcript for that message range, capped at ~15K tokens.`;
34+
2935
const SISYPHUS_SECTION = `
3036
### Reduction Triggers
3137
- After collecting background agent results (explore/librarian) — drop raw outputs once you extracted what you need.
@@ -182,7 +188,16 @@ export function detectAgentFromSystemPrompt(systemPrompt: string): AgentType | n
182188
return null;
183189
}
184190

185-
export function buildMagicContextSection(agent: AgentType | null, protectedTags: number): string {
191+
export function buildMagicContextSection(
192+
agent: AgentType | null,
193+
protectedTags: number,
194+
ctxReduceEnabled = true,
195+
): string {
196+
if (!ctxReduceEnabled) {
197+
return `## Magic Context
198+
199+
${BASE_INTRO_NO_REDUCE}`;
200+
}
186201
const section = agent ? AGENT_SECTIONS[agent] : GENERIC_SECTION;
187202
return `## Magic Context
188203

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ describe("MagicContextConfigSchema", () => {
4747
it("parses an enabled config without stale reduction-specific keys", () => {
4848
const input = {
4949
enabled: true,
50+
ctx_reduce_enabled: true,
5051
cache_ttl: "10m",
5152
protected_tags: 3,
5253
nudge_interval_tokens: 15_000,

src/config/schema/magic-context.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ export type DreamingConfig = z.infer<typeof DreamingConfigSchema>;
124124

125125
export interface MagicContextConfig {
126126
enabled: boolean;
127+
/** When false, ctx_reduce tool is not registered, all nudges are disabled,
128+
* and prompt guidance about ctx_reduce is stripped. Heuristic cleanup,
129+
* compartments, memory, and other features continue to work. Default: true. */
130+
ctx_reduce_enabled: boolean;
127131
historian?: z.infer<typeof AgentOverrideConfigSchema>;
128132
dreamer?: DreamerConfig;
129133
cache_ttl: string | { default: string; [modelKey: string]: string };
@@ -150,6 +154,10 @@ export const MagicContextConfigSchema = z
150154
.object({
151155
/** Enable magic context (default: false) */
152156
enabled: z.boolean().default(false),
157+
/** When false, ctx_reduce tool is hidden, all nudges disabled, and prompt
158+
* guidance about ctx_reduce stripped. Heuristic cleanup, compartments,
159+
* memory, and other features still work. (default: true) */
160+
ctx_reduce_enabled: z.boolean().default(true),
153161
/** Historian agent configuration (model, fallback_models, variant, temperature, maxTokens, permission, etc.) */
154162
historian: AgentOverrideConfigSchema.optional(),
155163
/** Dreamer agent + scheduling configuration (model, fallback_models, enabled, schedule, tasks, etc.) */

src/hooks/magic-context/hook-handlers.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,22 +50,27 @@ export function createChatMessageHook(args: {
5050
variantBySession: VariantBySession;
5151
flushedSessions: FlushedSessions;
5252
lastHeuristicsTurnId: LastHeuristicsTurnId;
53+
ctxReduceEnabled?: boolean;
5354
}) {
5455
return async (input: { sessionID?: string; variant?: string }) => {
5556
const sessionId = input.sessionID;
5657
if (!sessionId) return;
5758

58-
const sessionMeta = getOrCreateSessionMeta(args.db, sessionId);
59-
const turnUsage = args.toolUsageSinceUserTurn.get(sessionId);
60-
const agentAlreadyReduced = args.recentReduceBySession.has(sessionId);
61-
if (
62-
!sessionMeta.isSubagent &&
63-
!agentAlreadyReduced &&
64-
getPersistedStickyTurnReminder(args.db, sessionId) === null &&
65-
turnUsage !== undefined &&
66-
turnUsage >= TOOL_HEAVY_TURN_REMINDER_THRESHOLD
67-
) {
68-
setPersistedStickyTurnReminder(args.db, sessionId, TOOL_HEAVY_TURN_REMINDER_TEXT);
59+
// Only set sticky turn reminders when ctx_reduce is enabled — the reminder
60+
// tells the agent to use ctx_reduce, which doesn't exist when disabled.
61+
if (args.ctxReduceEnabled !== false) {
62+
const sessionMeta = getOrCreateSessionMeta(args.db, sessionId);
63+
const turnUsage = args.toolUsageSinceUserTurn.get(sessionId);
64+
const agentAlreadyReduced = args.recentReduceBySession.has(sessionId);
65+
if (
66+
!sessionMeta.isSubagent &&
67+
!agentAlreadyReduced &&
68+
getPersistedStickyTurnReminder(args.db, sessionId) === null &&
69+
turnUsage !== undefined &&
70+
turnUsage >= TOOL_HEAVY_TURN_REMINDER_THRESHOLD
71+
) {
72+
setPersistedStickyTurnReminder(args.db, sessionId, TOOL_HEAVY_TURN_REMINDER_TEXT);
73+
}
6974
}
7075
args.toolUsageSinceUserTurn.set(sessionId, 0);
7176

@@ -103,6 +108,7 @@ export function createEventHook(args: {
103108
commitSeenLastPass?: Map<string, boolean>;
104109
client: PluginContext["client"];
105110
protectedTags: number;
111+
ctxReduceEnabled?: boolean;
106112
}) {
107113
return async (input: { event: { type: string; properties?: unknown } }) => {
108114
await args.eventHandler(input);
@@ -141,6 +147,10 @@ export function createEventHook(args: {
141147
return;
142148
}
143149

150+
// Skip 80% emergency nudge when ctx_reduce is disabled — it tells the
151+
// agent to "STOP AND COMPRESS" which requires ctx_reduce.
152+
if (args.ctxReduceEnabled === false) return;
153+
144154
if (args.emergencyNudgeFired.has(sessionId)) return;
145155

146156
const meta = getOrCreateSessionMeta(args.db, sessionId);

src/hooks/magic-context/hook.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface MagicContextDeps {
5656
compactionHandler: ReturnType<typeof createCompactionHandler>;
5757
config: {
5858
protected_tags: number;
59+
ctx_reduce_enabled?: boolean;
5960
nudge_interval_tokens?: number;
6061
auto_drop_tool_age?: number;
6162
clear_reasoning_age?: number;
@@ -146,13 +147,17 @@ export function createMagicContextHook(deps: MagicContextDeps) {
146147
const liveModelBySession = new Map<string, { providerID: string; modelID: string }>();
147148
const recentReduceBySession = new Map<string, number>();
148149
const toolUsageSinceUserTurn = new Map<string, number>();
149-
const nudgerWithRecentReduce = createNudger({
150-
protected_tags: deps.config.protected_tags,
151-
nudge_interval_tokens: deps.config.nudge_interval_tokens ?? DEFAULT_NUDGE_INTERVAL_TOKENS,
152-
iteration_nudge_threshold: deps.config.iteration_nudge_threshold ?? 15,
153-
execute_threshold_percentage: deps.config.execute_threshold_percentage ?? 65,
154-
recentReduceBySession,
155-
});
150+
const ctxReduceEnabled = deps.config.ctx_reduce_enabled !== false;
151+
const nudgerWithRecentReduce = ctxReduceEnabled
152+
? createNudger({
153+
protected_tags: deps.config.protected_tags,
154+
nudge_interval_tokens:
155+
deps.config.nudge_interval_tokens ?? DEFAULT_NUDGE_INTERVAL_TOKENS,
156+
iteration_nudge_threshold: deps.config.iteration_nudge_threshold ?? 15,
157+
execute_threshold_percentage: deps.config.execute_threshold_percentage ?? 65,
158+
recentReduceBySession,
159+
})
160+
: () => null;
156161

157162
const transform = createTransform({
158163
tagger: deps.tagger,
@@ -278,6 +283,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
278283
const systemPromptHashHandler = createSystemPromptHashHandler({
279284
db,
280285
protectedTags: deps.config.protected_tags,
286+
ctxReduceEnabled,
281287
flushedSessions,
282288
lastHeuristicsTurnId,
283289
});
@@ -296,6 +302,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
296302
commitSeenLastPass,
297303
client: deps.client,
298304
protectedTags: deps.config.protected_tags,
305+
ctxReduceEnabled,
299306
});
300307

301308
return {
@@ -309,6 +316,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
309316
variantBySession,
310317
flushedSessions,
311318
lastHeuristicsTurnId,
319+
ctxReduceEnabled,
312320
}),
313321
event: async (input: { event: { type: string; properties?: unknown } }) => {
314322
await eventHook(input);

src/hooks/magic-context/system-prompt-hash.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const MAGIC_CONTEXT_MARKER = "## Magic Context";
2727
export function createSystemPromptHashHandler(deps: {
2828
db: ContextDatabase;
2929
protectedTags: number;
30+
ctxReduceEnabled: boolean;
3031
flushedSessions: Set<string>;
3132
lastHeuristicsTurnId: Map<string, string>;
3233
}): (input: { sessionID?: string }, output: { system: string[] }) => Promise<void> {
@@ -38,7 +39,11 @@ export function createSystemPromptHashHandler(deps: {
3839
const fullPrompt = output.system.join("\n");
3940
if (fullPrompt.length > 0 && !fullPrompt.includes(MAGIC_CONTEXT_MARKER)) {
4041
const detectedAgent = detectAgentFromSystemPrompt(fullPrompt);
41-
const guidance = buildMagicContextSection(detectedAgent, deps.protectedTags);
42+
const guidance = buildMagicContextSection(
43+
detectedAgent,
44+
deps.protectedTags,
45+
deps.ctxReduceEnabled,
46+
);
4247
output.system.push(guidance);
4348
sessionLog(
4449
sessionId,

src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getMagicContextBuiltinCommands } from "./features/builtin-commands/comm
77
import { DREAMER_SYSTEM_PROMPT } from "./features/magic-context/dreamer/task-prompts";
88
import { SIDEKICK_SYSTEM_PROMPT } from "./features/magic-context/sidekick/agent";
99
import { COMPARTMENT_AGENT_SYSTEM_PROMPT } from "./hooks/magic-context/compartment-prompt";
10+
import { startDreamScheduleTimer } from "./plugin/dream-timer";
1011
import { createEventHandler } from "./plugin/event";
1112
import { createSessionHooks } from "./plugin/hooks/create-session-hooks";
1213
import { createMessagesTransformHandler } from "./plugin/messages-transform";
@@ -31,6 +32,15 @@ const plugin: Plugin = async (ctx) => {
3132
pluginConfig,
3233
});
3334

35+
// Start independent dream schedule timer at plugin level (not inside hooks)
36+
// so overnight dreaming works even when the user isn't chatting.
37+
if (pluginConfig.dreamer) {
38+
startDreamScheduleTimer({
39+
client: ctx.client,
40+
dreamerConfig: pluginConfig.dreamer,
41+
});
42+
}
43+
3444
return {
3545
tool: tools,
3646
event: createEventHandler({ magicContext: hooks.magicContext }),

src/plugin/dream-timer.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { DreamerConfig } from "../config/schema/magic-context";
2+
import { checkScheduleAndEnqueue, processDreamQueue } from "../features/magic-context/dreamer";
3+
import { openDatabase } from "../features/magic-context/storage";
4+
import { log } from "../shared/logger";
5+
import type { PluginContext } from "./types";
6+
7+
/** Check interval for dream schedule (15 minutes). */
8+
const DREAM_TIMER_INTERVAL_MS = 15 * 60 * 1000;
9+
10+
/**
11+
* Start an independent timer that checks the dreamer schedule and processes
12+
* the dream queue. This runs regardless of user activity so overnight
13+
* dreaming triggers even when the user isn't chatting.
14+
*
15+
* The timer is unref'd so it doesn't prevent the process from exiting.
16+
*/
17+
export function startDreamScheduleTimer(args: {
18+
client: PluginContext["client"];
19+
dreamerConfig: DreamerConfig;
20+
}): void {
21+
const { client, dreamerConfig } = args;
22+
23+
if (!dreamerConfig.enabled || !dreamerConfig.schedule?.trim()) {
24+
return;
25+
}
26+
27+
const timer = setInterval(() => {
28+
try {
29+
const db = openDatabase();
30+
checkScheduleAndEnqueue(db, dreamerConfig.schedule);
31+
32+
void processDreamQueue({
33+
db,
34+
client,
35+
tasks: dreamerConfig.tasks,
36+
taskTimeoutMinutes: dreamerConfig.task_timeout_minutes,
37+
maxRuntimeMinutes: dreamerConfig.max_runtime_minutes,
38+
}).catch((error: unknown) => {
39+
log("[dreamer] timer-triggered queue processing failed:", error);
40+
});
41+
} catch (error) {
42+
log("[dreamer] timer-triggered schedule check failed:", error);
43+
}
44+
}, DREAM_TIMER_INTERVAL_MS);
45+
46+
// Unref so the timer doesn't prevent the process from exiting.
47+
if (typeof timer === "object" && "unref" in timer) {
48+
timer.unref();
49+
}
50+
51+
log(
52+
`[dreamer] started independent schedule timer (every ${DREAM_TIMER_INTERVAL_MS / 60_000}m)`,
53+
);
54+
}

src/plugin/tool-registry.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,14 @@ export function createToolRegistry(args: {
6969
}
7070
}
7171

72+
const ctxReduceEnabled = pluginConfig.ctx_reduce_enabled !== false;
7273
const allTools: Record<string, ToolDefinition> = {
73-
...createCtxReduceTools({
74-
db,
75-
protectedTags: pluginConfig.protected_tags ?? DEFAULT_PROTECTED_TAGS,
76-
}),
74+
...(ctxReduceEnabled
75+
? createCtxReduceTools({
76+
db,
77+
protectedTags: pluginConfig.protected_tags ?? DEFAULT_PROTECTED_TAGS,
78+
})
79+
: {}),
7780
...createCtxExpandTools(),
7881
...createCtxNoteTools({ db }),
7982
...(memoryEnabled

0 commit comments

Comments
 (0)