MyPrototypeWhat

ContextChef (8): Five Extension Points in the Compile Pipeline

中文版:ContextChef (8):编译管道里的五个扩展点

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 payload

onMemoryUpdate 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 of compile(), before Janitor runs
  • compile:done — emitted after compile() produces the final payload
  • compress — emitted after Janitor compresses history
  • memory:changed — emitted after any memory mutation (set, delete, expire)
  • memory:expired — emitted when a memory entry expires during compile()

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?