ContextChef (8): Five Extension Points in the Compile Pipeline
12 Mar 2026
6 min read
ContextChef is a closed compilation pipeline by default: you feed in state, it outputs a payload, and the internal decisions about ordering, compression, and formatting are handled by the individual modules. Most of the time, that’s exactly what you want — no detail management, just results.
But some requirements fall outside the module boundaries: you want to do a lossless cleanup pass before compression triggers; you want to inject real-time retrieval results before every generation; you want an audit log of every memory write; you want to make a last-minute adjustment to the message array. None of these require forking the library or bypassing the pipeline — they correspond to five precise intervention windows inside it.
The compile() Execution Order
compile() called
↓
1. Janitor: check token budget
├─ onBeforeCompress (when over limit, before compression)
└─ onCompress (after compression completes)
↓
2. onBeforeCompile (after Janitor, before assembly)
↓
3. Memory injection
↓
4. Sandwich assembly (Assembler + Guardrail)
↓
5. transformContext (after assembly, before Adapter)
↓
6. Adapter formatting
↓
output payloadonMemoryUpdate is outside the compile() flow — it fires independently on any Memory write.
onBeforeCompress: The Pre-Compression Intervention Window
When it fires: Janitor detects that token usage exceeds contextWindow, before lossy summarization begins.
What it’s for: Doing lossless pre-processing before lossy compression. The most common pattern: offload large tool results from history to VFS first, and see if that alone brings the token count under budget — avoiding summarization and the information loss that comes with it. Return a modified history array and Janitor uses it; return null to skip pre-processing and go straight to compression.
This hook makes the “lossless first” strategy explicitly implementable — summarization is the last resort, not the first response to an overrun.
onCompress: Post-Compression Notification
When it fires: After compression actually happens, receiving the summary message and the count of compressed messages.
What it’s for: Notification-type side effects — showing a progress indicator in the UI, saving the compression summary to a log, triggering external state updates. Not for modifying the compression result.
onBeforeCompile: Just-in-Time Context Injection
When it fires: After Janitor finishes, before sandwich assembly. Receives a read-only snapshot of the current systemPrompt, history, dynamicState, and dynamicStateXml.
What it’s for: The standard interface for connecting ContextChef to external context sources. Do RAG retrieval, MCP queries, code AST extraction here — any context that should be loaded on-demand before each generation belongs here. Return a string and ContextChef wraps it in <implicit_context> tags, injecting it at the same position as dynamic state (last_user or system), without disturbing KV-cache stability.
onBeforeCompile: async ({ dynamicStateXml }) => {
// dynamicStateXml is the XML serialization of current task state — use it directly as the search query
const results = await vectorDB.search(dynamicStateXml, { topK: 3 });
return results.map(r => r.content).join('\n');
}Using dynamicStateXml as the retrieval query is a design detail worth noting: it’s structured task state with more stable semantics than a random user message, which tends to give better retrieval precision. This hook directly corresponds to Anthropic’s just-in-time context philosophy — maintain lightweight identifiers, load full data only when actually needed.
Memory’s Three Hooks
The Memory module has three hooks with distinct responsibilities — they need to be understood separately.
onMemoryUpdate: The veto hook for model-initiated writes. Fires when the model triggers createMemory / updateMemory / deleteMemory via tool calls. It does not fire for direct chef.getMemory().set() calls. Return false to block the write. Typical uses: restrict the model to specific keys only, run content safety checks on model-written values.
onMemoryChanged: Full-coverage change notification. Fires on any memory change — set, delete, or TTL expiry — receiving a structured MemoryChangeEvent: { type: 'set' | 'delete' | 'expire', key, value, oldValue }. Cannot veto; can only observe. Typical uses: audit logging, external sync (database, Redis).
onMemoryExpired: TTL expiry notification. Fires when compile() sweeps an expired entry, receiving the full MemoryEntry (including importance, updateCount, and other metadata). Cannot veto; can only observe. Typical uses: log which entries were cleaned up by TTL, trigger post-expiry side effects.
The relationship between the three: onMemoryUpdate is the only gatekeeper on the write path, but only for model-initiated writes; onMemoryChanged is the universal observer covering all change sources; onMemoryExpired is the TTL-specific variant of onMemoryChanged (type: ‘expire’) that provides richer entry metadata. allowedKeys validation happens before onMemoryUpdate — writes blocked by allowedKeys don’t trigger any hook.
transformContext: The Ultimate Escape Hatch
When it fires: After the complete sandwich is assembled, before Adapter formatting. Receives the full Message[], returns a modified array.
What it’s for: Cases that none of the four hooks above can cover. You can do any message-level operation here — delete, insert, modify content, reorder. In practice, if you find yourself reaching for transformContext regularly, it usually signals that either a module doesn’t cover your scenario, or there’s a more optimal way to structure your integration. Treat it as a “there genuinely is an edge case” tool, not a routine part of the pipeline.
Beyond Hooks: The Unified Event System
The five hooks above are intervention points — they can modify data or veto operations. But sometimes you just want to observe what’s happening without affecting the pipeline. That’s what the event system is for.
chef.on() / chef.off() subscribe to lifecycle events that fire at the same positions as hooks, but with a key difference: events are pure notification — they cannot modify data or block operations.
Five events are available:
compile:start— emitted at the very start ofcompile(), before Janitor runscompile:done— emitted aftercompile()produces the final payloadcompress— emitted after Janitor compresses historymemory:changed— emitted after any memory mutation (set, delete, expire)memory:expired— emitted when a memory entry expires duringcompile()
The distinction matters: hooks like onBeforeCompress and onMemoryUpdate are part of the control flow — they can change outcomes. Events are outside the control flow — they’re for observability, metrics, logging, and external sync. If you need to track how often compression fires, measure compile latency, or stream memory changes to a dashboard, use events. If you need to prevent a write or modify history, use hooks.
Five intervention hooks, five observation events — each at a precise position in the pipeline. The only criterion for choosing which to use: does its trigger timing match when you need to act, and do you need to modify the outcome or just observe it?