Context compiler for TypeScript/JavaScript AI agents.
ContextChef solves the most common context engineering problems in AI agent development: conversations too long for the model to remember, too many tools causing hallucinations, having to rewrite prompts when switching providers, and state drift in long-running tasks. It doesn't take over your control flow — it just compiles your state into an optimal payload before each LLM call.
| Package | Description |
|---|---|
@context-chef/core |
Core context compiler — overflow strategies, tool pruning, one addressed context store, pipeline slots, multi-provider adapters |
@context-chef/ai-sdk-middleware |
Vercel AI SDK middleware — drop-in context engineering with zero code changes |
@context-chef/tanstack-ai |
TanStack AI middleware — compression, truncation, and dynamic state via ChatMiddleware |
If you use the Vercel AI SDK, you can get transparent history compression and tool result truncation with just 2 lines:
import { withContextChef } from '@context-chef/ai-sdk-middleware';
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
const model = withContextChef(openai('gpt-4o'), {
contextWindow: 128_000,
compress: { model: openai('gpt-4o-mini') },
truncate: { threshold: 5000 },
});
// Everything below stays exactly the same
const result = await generateText({ model, messages, tools });See the @context-chef/ai-sdk-middleware README for full documentation.
If you use TanStack AI, drop in the middleware for transparent context management:
import { contextChefMiddleware } from '@context-chef/tanstack-ai';
import { chat } from '@tanstack/ai';
import { openaiText } from '@tanstack/ai-openai';
const stream = chat({
adapter: openaiText('gpt-4o'),
messages,
middleware: [
contextChefMiddleware({
contextWindow: 128_000,
compress: { adapter: openaiText('gpt-4o-mini') },
truncate: { threshold: 5000 },
}),
],
});See the @context-chef/tanstack-ai README for full documentation.
For direct control over the compilation pipeline — dynamic state injection, tool namespaces, memory, snapshot/restore — use the core library directly:
- Why "Compile" Your Context
- Janitor — Separating Trigger Logic from Compression Policy
- Pruner — Decoupling Tool Registration from Routing
- Offloader/VFS — Relocate Information, Don't Destroy It
- Core Memory — Zero-Cost Reads, Structured Writes
- Snapshot & Restore — Capture Everything That Determines the Next Compile
- The Provider Adapter Layer — Let Differences Stop at Compile Time
- Five Extension Points in the Compile Pipeline
ContextChef compiles unbounded information into a bounded window. Every feature in this README sits on exactly one of five axes — that is the whole map:
| Axis | Question | What answers it |
|---|---|---|
| ① Selection | What goes into the window | overflow.strategy (history), Pruner (tools), memory.selector (facts), pinned: true (what can never leave) |
| ② Placement | Where in the window it goes | the sandwich — system layers → history → tail stitch — plus memoryPlacement, skillPlacement, dynamicStatePlacement, announcement channels, and the cache-stable prefix |
| ③ Persistence | Where it lives when it is out of the window | one StorageBackend behind one Store, addressed as context://<ns>/<path>: memory/, notes/, vfs/, archive/ |
| ④ Retrieval | How the model reaches back outside the window | the context tool (+ new_context), dispatched through chef.ownsTool / chef.handleTool |
| ⑤ Adaptation | Provider wire format | target adapters — OpenAI / Anthropic / Gemini / your own |
Overflow is not a sixth thing: it is information moving from ① to ③, recoverable via ④. summarize / anchored / reset are ① policies, memory/ / notes/ / vfs/ / archive/ are ③ namespaces, view / search / recall_context are ④ handles. When you are looking for "where does X live", find X's axis first.
Everything on those axes is mechanism, not policy: no combination of choices is rejected, and the failure classes that cannot be prevented (a rewritten cache prefix, a dropped pinned message, a split tool pair) are reported through the audit and event channels rather than blocked.
① Selection — what stays in the window
- Conversations too long? — Automatically compress history, preserve recent memory, delegate old messages to a small model for summarization
- Losing constraints to compression? — Constraint pinning (v4):
pinned: truemessages survive compression verbatim and are never cleared bycompact() - One policy doesn't fit? — Overflow strategies (4.2):
summarize/anchored/reset, composed withchain()andbackground(), or your ownOverflowStrategy - Provider does compaction for you? —
server()strategy delegates LLM compression to Anthropic's server-side compaction while pruning/skills/memory/VFS stay client-side - Compression latency on the hot path? —
background()runs summarization off-turn and swaps it in only when still valid;anchored()merges evicted spans into a persistent summary document instead of rewriting it - Own your message store? — Durable compaction:
planCompaction/compactHistory(plus AI SDK and TanStack ports) compact your store once and persist the result, instead of re-compressing in-flight on every call - Window about to be cut? — Handoff budget (4.2): reserve tokens above the trigger and the model gets one notice to persist state before eviction happens mechanically
- Too many tools? — Dynamically prune the tool list per task, or use a two-layer architecture (stable namespaces + on-demand loading) to eliminate tool hallucinations
- Need to block tools at runtime? — Pruner blocklist +
checkToolCallgate for permission, environment safety, rate limits, and sandboxing — KV-cache preserving by default
② Placement — where in the window it lands
- Mode-based behavior? —
Skillprimitive bundles instructions and tool annotations per phase; loadable fromSKILL.mdfiles (compatible with Claude Code / Mastra / OpenCode formats) - Capabilities change mid-session? — Announcements (4.1):
announce()states what tools/skills came or went and re-renders it into every compile until retracted;skillPlacement: 'tail'moves skill instructions out of the cached prefix so switching modes never invalidates it - Long tasks drifting? — Zod schema-based state injection forces the model to stay aligned with the current task on every call
- Output format drifting? — Guardrail:
withGuardrailsenforces an XML output contract and sets an assistant prefill, auto-degraded on providers without native prefill - Prompt cache invalidated every turn? —
memoryPlacement: 'before_history_tail'andskillPlacement: 'tail'keep volatile text out of the cached prefix;cacheAuditnames anything left inside it
③ Persistence — where it lives once it is out of the window
- Terminal output too large? — Auto-truncate and offload to
vfs/, keeping error lines + acontext://URI pointer for on-demand retrieval - Can't remember across sessions? —
memory/lets the model persist key information (project rules, user preferences), auto-injected on the next session - Summary dropped a detail you need back? — Reversible archive: the full pre-overflow span is stored and cited by URI in the summary that replaced it
- Four storage interfaces for one job? — One
StorageBackendbehind oneStore(4.2): memory, notes, offloaded output and archived spans are the same substrate at different addresses - Need to rollback? — Snapshot & Restore captures and rolls back full context state for branching and exploration
④ Retrieval — how the model reaches back
- A tool per namespace? — One
contexttool (4.2) covering view / create / str_replace / insert / delete / rename / search, dispatched throughchef.handleTool; opt in withtools: 'unified' - Model knows the chunk is finished? —
new_context+chef.requestNewContext()(4.2) force the next compile to open a fresh window - Need external context? — The
before-assembleslot (onBeforeCompilebefore 4.2) lets you inject RAG results, AST snippets, or MCP queries before compilation
⑤ Adaptation — provider wire format
- Switching providers? — Same prompt architecture compiles to OpenAI / Anthropic / Gemini with automatic prefill, cache, and tool call format adaptation
Across the pipeline
- Need to compose? — Pipeline slots (4.2):
chef.use('before-overflow' | 'after-overflow' | 'before-assemble' | 'after-assemble' | 'before-adapt' | 'after-adapt', handler) - Need observability? — Unified event system (
chef.on('compress', ...)) for logging, metrics, and debugging across all internal modules
npm install @context-chef/core zodimport { ContextChef } from "@context-chef/core";
import { z } from "zod";
const TaskSchema = z.object({
activeFile: z.string(),
todo: z.array(z.string()),
});
const chef = new ContextChef({
janitor: {
contextWindow: 200000,
compressionModel: async (msgs) => callGpt4oMini(msgs),
},
});
const payload = await chef
.setSystemPrompt([
{
role: "system",
content: "You are an expert coder.",
_cache_breakpoint: true,
},
])
.setHistory(conversationHistory)
.setDynamicState(TaskSchema, {
activeFile: "auth.ts",
todo: ["Fix login bug"],
})
.withGuardrails({
enforceXML: { outputTag: "response" },
prefill: "<thinking>\n1.",
})
.compile({ target: "anthropic" });
const response = await anthropic.messages.create(payload);Removed in 4.0 — each has a direct replacement:
TokenUtils→estimate/estimateObject,XmlGenerator→objectToXml,AdapterFactory→getAdapter/adapterRegistry,JanitorConfig.onBudgetExceeded→onBeforeCompress. See MIGRATION-4.md for the full migration guide.
const chef = new ContextChef({
janitor?: JanitorConfig, // ① when the window is over budget
overflow?: { strategy?: OverflowStrategy, archive?: 'vfs' | CompressionArchiveConfig, handoff?: { budgetTokens: number, prompt?: string } },
pruner?: { strategy?: 'union' | 'intersection' },
memory?: MemoryConfig, // ③ the memory/ namespace
vfs?: { threshold?: number, store?: StorageBackend | Store, storageDir?: string, maxAge?: number, maxFiles?: number, maxBytes?: number, onVFSEvicted?: (entry, reason) => void },
store?: StorageBackend | Store, // ③ one backend for every namespace
tools?: 'legacy' | 'unified', // ④ which library-owned tools compile() emits
contextTool?: { writable?: string[] }, // ④ namespaces the model may write to
skillPlacement?: 'after_system' | 'tail', // ②
defaultTarget?: TargetProvider | ITargetAdapter, // ⑤
logger?: ChefLogger,
cacheAudit?: boolean, // warn on volatile content inside the cached prefix
pipelineChecks?: boolean, // dev invariants, reported never enforced
transformToolResult?: (content: string, info: { toolName: string | null; toolCallId: string | null }) => string | Promise<string>,
transformContext?: (messages: Message[]) => Message[] | Promise<Message[]>, // @deprecated → use('after-assemble')
onBeforeCompile?: (context: BeforeCompileContext) => string | null | Promise<string | null>, // @deprecated → use('before-assemble')
});Sets the static system prompt layer. Cached prefix — should rarely change.
chef.setSystemPrompt([
{
role: "system",
content: "You are an expert coder.",
_cache_breakpoint: true,
},
]);_cache_breakpoint: true tells the Anthropic adapter to inject cache_control: { type: 'ephemeral' }.
Sets the conversation history. Janitor compresses automatically on compile().
Injects Zod-validated state as XML into the context.
const TaskSchema = z.object({
activeFile: z.string(),
todo: z.array(z.string()),
});
chef.setDynamicState(TaskSchema, { activeFile: "auth.ts", todo: ["Fix bug"] });
// placement defaults to 'last_user' (injected into the last user message)
// use { placement: 'system' } for a standalone system messageApplies output format guardrails and optional prefill.
chef.withGuardrails({
enforceXML: { outputTag: "final_code" }, // wraps output rules in EPHEMERAL_MESSAGE
prefill: "<thinking>\n1.", // trailing assistant message (auto-degraded for OpenAI/Gemini)
});v4 semantics. Options are now stored and applied at compile(), so call order relative to setDynamicState no longer matters (pre-4.0, calling setDynamicState after withGuardrails silently discarded the guardrail). Each call replaces the previous options (no accumulation); withGuardrails(null) clears them. The stored options are persisted in ChefSnapshot (guardrailOptions), and the guardrail message lands at the very end of the sandwich as its own message — closest to generation, no longer merged into the dynamic-state message.
Cache-safe placement (4.1). placement: 'last_user' delivers the enforce-XML instruction through the last-user-message tail injection (same channel as dynamic state) instead of a system message. This matters on Anthropic: every role: 'system' message is hoisted into the top-level system prefix, so under the default placement: 'system' any guardrail change invalidates cache breakpoints downstream of it. prefill is unaffected either way.
Compiles everything into a provider-ready payload. Triggers Janitor compression. Registered tools are auto-included.
const payload = await chef.compile({ target: "openai" }); // OpenAIPayload
const payload = await chef.compile({ target: "anthropic" }); // AnthropicPayload
const payload = await chef.compile({ target: "gemini" }); // GeminiPayloadJanitor provides two compression paths. Choose the one that fits your setup:
Provide your own token counting function for precise per-message calculation. Janitor preserves recent messages that fit within contextWindow × preserveRatio and compresses the rest.
const chef = new ContextChef({
janitor: {
contextWindow: 200000,
tokenizer: (msgs) =>
msgs.reduce((sum, m) => sum + encode(m.content).length, 0),
preserveRatio: 0.8, // keep 80% of contextWindow for recent messages (default)
compressionModel: async (msgs) => callGpt4oMini(msgs),
onCompress: async (summary, count, details) => {
// details.compressedMessages — the exact slice of history the summary replaced
await db.saveCompression(sessionId, summary, count);
},
},
});Don't hand-roll the counting function — createTokenizerAdapter (4.1) wraps any (text) => tokens encoder and encodes the fields-counted convention once (content + thinking + redacted data + tool-call names/arguments — the field hand-rolled adapters most often miss, and where write/edit tools carry the bulk of a coding-agent span):
import { createTokenizerAdapter } from "@context-chef/core";
import { encode } from "gpt-tokenizer"; // or js-tiktoken, etc. — your dependency, not ours
const chef = new ContextChef({
janitor: { contextWindow: 200_000, tokenizer: createTokenizerAdapter(encode) },
});Cross-provider counts are approximate (o200k is exact only for OpenAI), which is safe under the default triggerRatio: 0.7 headroom; for exact accounting use reportTokenUsage() with provider-reported usage.
Most LLM APIs return token usage in their response. Feed that value back — when it exceeds contextWindow, Janitor compresses everything except the last N messages.
const chef = new ContextChef({
janitor: {
contextWindow: 200000,
preserveRecentMessages: 1, // keep last 1 message on compression (default)
compressionModel: async (msgs) => callGpt4oMini(msgs),
},
});
// After each LLM call:
const response = await openai.chat.completions.create({ ... });
chef.reportTokenUsage(response.usage.prompt_tokens);Note: Without a
compressionModel, old messages are discarded with no summary. A console warning is printed once at construction if neithertokenizernorcompressionModelis provided, unless an explicitoverflow.strategy(orjanitor.strategy) says you meant it.
Runner options — when compression fires, what it is measured against, and what happens around it. These are Janitor's own and are unaffected by which overflow strategy is installed:
| Option | Type | Default | Description |
|---|---|---|---|
contextWindow |
number |
required | Model's context window size (tokens). Compression triggers when usage exceeds contextWindow × triggerRatio. |
triggerRatio |
number |
0.7 |
Fraction of contextWindow at which compression fires ("pre-rot"). Set 1 to restore the pre-4.0 trigger-at-window behavior. |
tokenizer |
(msgs: Message[]) => number |
— | Enables the tokenizer path for precise per-message token calculation. |
usagePreference |
'max' | 'feedFirst' | 'tokenizerFirst' |
'max' |
Which token source drives the trigger when both tokenizer and reportTokenUsage are set. Without tokenizer, the value union narrows to 'max' | 'feedFirst' — TypeScript rejects 'tokenizerFirst' at compile time. See the core package README for the full breakdown. |
onCompress |
(summary, count, details) => void |
— | Fires after compression with the summary message and truncated count. details.compressedMessages is the exact slice of history the summary replaced. |
onBeforeCompress |
(history, tokenInfo) => Message[] | null |
— | Fires once the budget verdict says overflow will run, before the strategy. Return modified history to intervene, or null to proceed normally. Not deprecated, and not the before-overflow slot — that one is chef-level and fires on every compile. |
logger |
ChefLogger |
— | Sink for degradation warnings (storage/compaction); defaults to console. |
strategy |
OverflowStrategy |
— | The policy the runner applies. When building a Janitor directly; through ContextChef use overflow.strategy. |
Policy options — the same fields as ever, now the options of summarize() / anchored(). Set here (the 4.1 spelling) they build the default strategy; set an explicit overflow.strategy and it wins, with a one-time warning:
| Option | Type | Default | Description |
|---|---|---|---|
compressionModel |
(msgs: Message[]) => Promise<string> |
— | Async hook to summarize old messages via a low-cost LLM. |
customCompressionInstructions |
string |
— | Additional focused instructions appended to the default compression prompt (additive, not replacement). |
compressionGuidelines |
string[] |
— | Numbered domain guidelines injected into the compression prompt, before customCompressionInstructions. |
toolResultStubThreshold |
number |
— | Replace tool-result content longer than this many chars with a one-line metadata stub before summarizing (saves summarizer tokens). |
minShrinkRatio |
number |
0.5 |
Quality gate: a summary must shrink the compressed span by at least this ratio (spans ≥ 2000 chars only); otherwise the compression fails and history is unchanged. 0 disables. |
validateCompression |
(summary, { compressed, kept }) => boolean | Promise<boolean> |
— | Post-summarization gate. Return false (or throw) to reject the summary — history unchanged, circuit breaker incremented. |
preserveRatio |
number |
0.8 |
[Tokenizer path] Ratio of the effective budget (contextWindow × triggerRatio) to preserve for recent messages. |
preserveRecentMessages |
number |
1 |
[reportTokenUsage path] Number of recent turns to keep when compressing. |
archive (deprecated) |
CompressionArchiveConfig | 'vfs' |
— | → overflow.archive, which applies to every strategy. Reversible compression: store the full pre-compression span, cite its URI in the summary. |
compressionMode (deprecated) |
'rewrite' | 'incremental-anchored' |
'rewrite' |
→ overflow.strategy: summarize(...) / anchored(...). |
compressionScheduling (deprecated) |
'blocking' | 'background' |
'blocking' |
→ overflow.strategy: background(...). |
Compression output contract. Janitor's default prompt instructs the compression model to produce an <analysis> scratchpad (stripped from the final output) followed by a structured <summary> block with 5 domain-agnostic sections (Task Overview / Current State / Important Discoveries / Next Steps / Context to Preserve). Raw output is piped through Prompts.formatCompactSummary before injection. See the core package README for the full contract and customCompressionInstructions usage.
Failure semantics (changed in 4.0). A compression-model failure — a throw, a summary that fails minShrinkRatio, or a validateCompression rejection — leaves history unchanged (pre-4.0 truncated it with a placeholder) and increments the circuit breaker. If three consecutive compress() calls fail, compress() becomes a no-op until the next successful compression or an explicit janitor.reset() / chef.clearHistory(). The failure counter is preserved by chef.snapshot() / chef.restore().
Standalone summarization. summarizeHistory(messages, compress, opts?): Promise<string> is the provider-agnostic primitive behind this path — call it directly to compress a slice in your own store. Empty slice → ''; stateless and throws if compress throws; the compress callback must role-flatten tool roles. Options include customCompressionInstructions, toolResultStubThreshold, compressionGuidelines, and baseInstruction. See the core package README for the full contract, and Durable compaction below for the higher-level helpers.
v4 rebuilt the compression path around one rule: a bad summary must never replace good history. These gates belong to the runner and the summarizing strategies, so they hold whatever overflow.strategy you install.
- Pre-rot trigger —
triggerRatio(default0.7): compression fires atcontextWindow × 0.7instead of at the hard limit — model quality degrades well before the window is full.preserveRatioapplies to this effective budget.triggerRatio: 1restores the pre-4.0 behavior. - Constraint pinning —
pinned: true: pinned messages survivecompress()verbatim (re-inserted after the summary, in order) and are never cleared bycompact(). Pinning any message of an atomic turn protects the whole turn. Every strategy receives the pinned set and must return it untouched. Use it for policy and constraint text — compaction that drops policy text raises violation rates from 0% to 30%+ (arXiv:2606.22528). - Shrink guard —
minShrinkRatio(default0.5): a summary that doesn’t shrink the compressed span by ≥ 50% (character length; spans ≥ 2000 chars only) is a failed compression — history unchanged, circuit breaker incremented. Prevents compression death loops.0disables. Underanchored()the guard compares anchor growth rather than absolute size. validateCompression: post-summarization gate(summary, { compressed, kept }) => boolean | Promise<boolean>— returnfalseor throw to reject the result (history unchanged, breaker incremented).compressionGuidelines: numbered domain guidelines injected into the compression prompt, beforecustomCompressionInstructions.
const chef = new ContextChef({
janitor: {
contextWindow: 200_000,
compressionModel: async (msgs) => callGpt4oMini(msgs),
triggerRatio: 0.7, // default — compress "pre-rot"
minShrinkRatio: 0.5, // default — reject summaries that barely shrink
compressionGuidelines: ["Preserve ticket IDs and SKUs verbatim."],
},
overflow: { archive: "vfs" }, // reversible: full span stored, summary cites a context:// URI
});
// Pin constraint text — survives compress() verbatim, never cleared by compact()
history.push({
role: "user",
content: "NEVER touch prod. Deploy only from CI.",
pinned: true,
});The three policy choices that used to live here — reversible archive, incremental-anchored mode, background scheduling — are strategies now: see Overflow. Reading an archived span back is in Recall.
In-flight compression (above) rewrites each outgoing payload but does not touch your message store — for a sustained over-budget conversation the summary is recomputed on every call. When you own the store, compact it once and persist the result. All three helpers split on atomic turn boundaries (an assistant message and its tool results never separate), summarize the old slice, and return [...system, <summary>, ...recent turns]; on a no-op the input reference is returned unchanged, so you can skip persistence via result === input.
Core — provider-agnostic, operates on IR Message[]:
import { compactHistory, planCompaction } from "@context-chef/core";
// myCompressFn must role-flatten tool messages (same contract as summarizeHistory)
history = await compactHistory(history, myCompressFn, { keepRecentTurns: 4 });
// planCompaction(history, { keepRecentTurns }) is the synchronous split behind itVercel AI SDK — ModelMessage altitude, role-flattening wired for you:
import { compactModelMessages } from "@context-chef/ai-sdk-middleware";
messages = await compactModelMessages(messages, openai("gpt-4o-mini"), {
keepRecentTurns: 4,
});TanStack AI — takes any TanStack text adapter:
import { compactTanStackMessages } from "@context-chef/tanstack-ai";
messages = await compactTanStackMessages(messages, openaiText("gpt-4o-mini"), {
keepRecentTurns: 4,
});keepRecentTurns: 0 is full Claude-Code-style compaction — the whole conversation collapses into [...system, <summary>]. Don't combine durable compaction with in-flight compression on the same conversation (double compression). See the core, ai-sdk-middleware, and tanstack-ai READMEs for the full contracts.
Feed the API-reported token count. On the next compile(), if this value exceeds contextWindow, compression is triggered. In the tokenizer path, the default is to take the higher of the local calculation and the fed value; switch via usagePreference if you want 'feedFirst' (trust the API truth) or 'tokenizerFirst' (ignore fed entirely).
const response = await openai.chat.completions.create({ ... });
chef.reportTokenUsage(response.usage.prompt_tokens);Fires when the token budget is exceeded, before LLM compression. Return a modified Message[] to replace the history, or return null to let default compression proceed.
const chef = new ContextChef({
janitor: {
contextWindow: 200000,
tokenizer: (msgs) => countTokens(msgs),
onBeforeCompress: (history, { currentTokens, limit }) => {
// Example: offload large tool results to VFS before compression
return history.map((msg) =>
msg.role === "tool" && msg.content.length > 5000
? { ...msg, content: pointer.offload(msg.content).content }
: msg,
);
},
},
});Strip content from history at zero LLM cost. Use proactively in your agent loop to keep context lean.
// Clear all tool results and thinking blocks
history = janitor.compact(history, { clear: ['tool-result', 'thinking'] });
// Keep the 5 most recent tool results, clear the rest (min: 1)
history = janitor.compact(history, {
clear: [{ target: 'tool-result', keepRecent: 5 }],
});
// Combine: clear old tool results + all thinking
history = janitor.compact(history, {
clear: [{ target: 'tool-result', keepRecent: 5 }, 'thinking'],
});
// v4: strip <think>...</think> tags from assistant text (open-weight reasoning models)
history = janitor.compact(history, { clear: ['reasoning-tags'] });
// v4: per-tool granularity — clear only these tools, never those
history = janitor.compact(history, {
clear: [{
target: 'tool-result',
keepRecent: 5, // counts within the clearable set
toolFilter: ['run_bash'], // only clear these tools
exemptTools: ['read_file'], // never clear these (wins over toolFilter)
}],
});Pinned messages (pinned: true) are never cleared by compact(), and Gemini thought signatures are immune to compact(['thinking']).
Standalone utility that sanitizes message history to satisfy LLM API invariants (orphan tool result removal, missing tool result placeholder injection, first-non-system-must-be-user). Use when loading history from a database or after manual modifications.
import { ensureValidHistory } from '@context-chef/core';
const safeHistory = ensureValidHistory(rawHistory);
chef.setHistory(safeHistory);Boundary contract. All input adapters (
fromOpenAI/fromAnthropic/fromGemini, plus middleware-internalfromAISDK/fromTanStackAI) run their output throughensureValidHistoryautomatically — they're the system boundary between external SDK formats and ContextChef IR.chef.setHistory(IR)does NOT sanitize: IR is treated as an internal protocol, and history you construct (or mutate) directly is trusted to satisfy the invariants. Wrap withensureValidHistory(...)explicitly when in doubt.
Explicitly clear history and reset Janitor state when switching topics or completing sub-tasks.
Janitor decides when the window is over budget. An OverflowStrategy decides what leaves it. Until 4.2 those were one class; they are now the runner and the policy, and the policy is a value you pass:
import { ContextChef, chain, summarize, reset } from "@context-chef/core";
const chef = new ContextChef({
janitor: { contextWindow: 200_000, tokenizer }, // runner: budget, trigger, breaker
overflow: {
strategy: chain(summarize({ compressionModel: callGpt4oMini }), reset()),
archive: "vfs", // the compressed span stays retrievable
handoff: { budgetTokens: 2_000 }, // one warning before the cut
},
});Nothing else moved. contextWindow, tokenizer, usagePreference, triggerRatio, onCompress, onBeforeCompress, logger, the circuit breaker, the compress:* events and durable compaction are runner concerns and stay on JanitorConfig exactly as they were. What overflow.strategy replaces is the janitor.compression* fields and contextManagement — see Deprecated overflow options; they still work.
Each is a factory in the barrel. Pick by what you are willing to pay:
| Strategy | What it does | What it costs |
|---|---|---|
summarize(opts) |
Replaces the oldest turns with an LLM summary of them. The 4.x default (compressionMode: 'rewrite'). |
One extra model call per overflow, and the whole summary is rewritten each time. Anything the summary omits is gone from the window unless archive is on. |
anchored(opts) |
Keeps a persistent anchor document and merges only the newly evicted span into it, instead of regenerating the summary from scratch (Factory.ai pattern). | The anchor grows monotonically and eventually needs its own budget. Its shrink guard compares anchor growth, not absolute size. |
server(config, { fallback }) |
On a target with server-side context management (today: Anthropic) the client never compresses — the overflow phase is skipped and the payload carries context_management + betas. |
On every other target there is nothing to delegate to: fallback runs, or history is left alone when none is given. |
reset(opts) |
Keeps the pinned messages, evicts everything else, leaves a one-line notice naming the closed window. | Zero model calls — and lossy unless archive is configured. That combination is documented, not enforced. |
chain(...strategies) |
Runs the next strategy when the previous one changed nothing or left the history still over the trigger. | Nothing on the happy path; a full escalation is every strategy's cost in one compile. |
background(strategy) |
Runs strategy off-turn: the over-budget compile returns history unchanged and a later compile swaps the result in — only while the summarized span is still the head of the current history (arXiv:2605.08580). |
One or more compiles go out over budget before the swap lands, stale results are discarded, and background state is not snapshotted. |
summarize() and anchored() take the same options — the fields that used to live on JanitorConfig:
summarize({
compressionModel: async (msgs) => callGpt4oMini(msgs),
compressionGuidelines: ["Preserve ticket IDs and SKUs verbatim."],
customCompressionInstructions: "Keep every unresolved question.",
minShrinkRatio: 0.5, // default — reject summaries that barely shrink
validateCompression: (summary, { compressed, kept }) => summary.includes("Next Steps"),
preserveRatio: 0.8, // [split: 'ratio'] keep this share of the trigger budget
preserveRecentMessages: 1, // [split: 'recent-turns'] keep this many turns
toolResultStubThreshold: 5_000,
split: "ratio", // defaults from which preserve option you set
});split is the one new field: 'ratio' prices the preserved tail with the runner's tokenizer, 'recent-turns' counts turns instead. Without an explicit overflow.strategy, the chef picks 'ratio' when a tokenizer is configured and 'recent-turns' otherwise — the same split the two Janitor paths always used.
Escalation is the reason chain exists:
// Summarize normally; when the summarizer is down or its output keeps failing
// the shrink guard, drop the window instead of growing forever.
overflow: {
strategy: chain(summarize({ compressionModel }), reset()),
archive: "vfs",
}A strategy is just an object, so your own is a first-class citizen:
import type { OverflowStrategy } from "@context-chef/core";
const dropToolResults: OverflowStrategy = {
name: "drop-tool-results",
async apply({ history, pinned, window }) {
const keep = new Set(pinned);
const evicted = history.filter((m) => m.role === "tool" && !keep.has(m));
return {
history: history.filter((m) => !evicted.includes(m)),
evicted,
span: evicted,
meta: { strategy: "drop-tool-results", windowId: window.current, changed: evicted.length > 0 },
};
},
};Optional commit(result), snapshot() and restore(state) round out the interface: apply may run speculatively (that is what background() does), so state derived from your own output — an anchor document — is published in commit, which only ever runs for a result that actually entered the window.
Archiving is strategy-agnostic since 4.2: whatever span a strategy compressed is serialized, stored, and cited by URI in the summary that replaced it, so exact details stay retrievable instead of being guessed at by importance scoring (arXiv:2607.25066, arXiv:2607.08032).
overflow: { strategy: reset(), archive: "vfs" } // store spans in this chef's VFS
overflow: { archive: { store: async (serialized, { messageCount }) => uploadToS3(serialized) } }Best-effort: a store failure logs a warning and skips the citation rather than failing the compile. In 4.x archived spans keep landing in the vfs namespace, so context://vfs/... URIs stay byte-identical to 4.1; the dedicated archive/ namespace is a 5.0 switch. Pair it with the context tool (or recall_context under the legacy tool set) so the model can pull a span back.
Overflow is mechanical: what a strategy evicts is gone from the window whether or not the model was ready for it. The handoff budget reserves a slice of headroom above the trigger and spends it on one notice, so the model can write what matters into the context store while the conversation is still in front of it.
const chef = new ContextChef({
janitor: { contextWindow: 200_000, tokenizer },
overflow: {
handoff: { budgetTokens: 4_000 }, // notice fires while remaining ≤ 4000
strategy: reset(),
archive: "vfs",
},
});- The notice is rendered once per window — a long stretch near the trigger does not repeat it every turn — and the flag resets when the window id changes.
- It rides the same tail channel as announcements (
channel: 'auto') and is never persisted: it does not enter your history, does not appear ingetAnnouncements(), and is skipped on server-managed compiles. - The default text is
Prompts.HANDOFF_NOTICE_TEMPLATE. A custompromptmay use{n_remaining}, replaced with the headroom left before the trigger (rounded, clamped at 0), and is capped at 2000 UTF-8 bytes. budgetTokensmust be a positive integer; both it and the prompt are validated at construction, not at the compile that would silently skip the notice.
overflow: {
handoff: {
budgetTokens: 4_000,
prompt:
"About {n_remaining} tokens remain before compression. " +
"Write anything that must survive to context://notes/handoff.md now.",
},
}chef.requestNewContext() forces the next compile() to run the overflow strategy whatever the budget says. Unlike clearHistory() it does not discard the conversation behind the library's back: the installed strategy still decides what survives, archive still applies, and a before-overflow handler can still veto it.
import { getNewContextToolDefinition } from "@context-chef/core";
chef.registerTools([getNewContextToolDefinition()]); // static, no parameters, cache-safe
// In your agent loop:
if (call.function.name === "new_context") {
chef.requestNewContext();
history.push({ role: "tool", tool_call_id: call.id, content: "Starting a new context window." });
}Under tools: 'unified' the definition is emitted for you whenever overflow.handoff is configured, and chef.handleTool dispatches it — see the context tool. The request is consumed by one compile whether or not the window actually changed (a strategy may decline; the circuit breaker may be open), so call it again if it must be retried.
Under summarize() and anchored() a forced pass compresses every turn but the most recent one — preserveRecentMessages and preserveRatio do not apply, since they exist to bring a full window under the trigger and this pass is not about the budget. With a single turn in the window nothing happens. Two compiles decline overflow outright — a server-managed target and a before-overflow veto — and a forced request dropped that way is reported through a pipeline:invariant event plus one warning per cause, never silently.
Each context window has an id. The runner allocates a new one at commit time, only when an overflow result actually enters the window — a stale background() result that gets discarded never advances it.
const payload = await chef.compile({ target: "anthropic" });
payload.meta?.windowId; // 'w_…' — changes exactly when history behind the model was rewrittenTwo payloads carrying the same id were compiled against the same window, so a change is the signal that the conversation the model can see was rewritten — useful for invalidating your own caches or writing a window boundary into your store. OverflowResult.meta.windowId is the window a strategy acted on; CompileMeta.windowId is the window the payload belongs to. anchored() keys its anchor document by window id, the lineage rides snapshot() / restore(), and clearHistory() starts a fresh one.
Providers now run compaction server-side (Anthropic compact_20260112, OpenAI /responses/compact) — one model call fewer and exact token accounting. server() delegates LLM compression to the provider; everything servers don't do stays client-side: tool pruning, skills, memory, VFS offloading, dynamic state.
import { server, summarize } from "@context-chef/core";
const chef = new ContextChef({
overflow: {
// Anthropic → the server compacts. Everything else → summarize locally.
strategy: server(undefined, { fallback: summarize({ compressionModel }) }),
},
});
const payload = await chef.compile({ target: "anthropic" });
// payload.context_management === { edits: [{ type: "compact_20260112" }] } (default when `config` omitted)
// payload.betas === ["compact-2026-01-12"] (auto-derived per edit type)- On a server-managed target the client-side overflow phase is skipped entirely; mechanical
compact()and every other module still run. - The first argument is a provider-shaped edits config passed through verbatim, e.g.
{ edits: [{ type: 'compact_20260112', trigger: { ... } }] }or{ edits: [{ type: 'clear_tool_uses_20250919' }] }.payload.betasis auto-derived:'compact-2026-01-12'for compaction edits,'context-management-2025-06-27'for the clear-tool-uses / clear-thinking edits. - Compaction block round-trip:
fromAnthropicmaps the API's{ type: 'compaction', content }blocks to passthrough messages markedpinned: true, and the Anthropic adapter re-emits the block first, verbatim, on the next compile — server-produced summaries survive the client pipeline untouched.
This is a hybrid positioning, not an either/or: LLM compression can be delegated to the provider while ContextChef keeps doing the parts servers don't — pruning, skills, memory, VFS, and dynamic state. AI SDK users get a matching guard: the middleware detects providerOptions.anthropic.contextManagement on a call and skips its own compression for that call (see the ai-sdk-middleware README).
The deprecated contextManagement: { strategy: 'server' } builds server(config, { fallback: <the default client strategy> }) for you, which is exactly the v4 behavior.
Every field below still works and is unchanged in behavior — each one now builds the strategy on the right. They are removed in 5.0; setting both a field and an explicit overflow.strategy logs a warning once and the strategy wins.
| Deprecated | Replacement |
|---|---|
janitor.compressionMode: 'rewrite' |
overflow.strategy: summarize(opts) |
janitor.compressionMode: 'incremental-anchored' |
overflow.strategy: anchored(opts) |
janitor.compressionScheduling: 'background' |
overflow.strategy: background(strategy) |
janitor.archive |
overflow.archive (now applies to every strategy, not just summarize) |
contextManagement: { strategy: 'server', server } |
overflow.strategy: server(server, { fallback }) |
janitor.{compressionModel, compressionGuidelines, customCompressionInstructions, minShrinkRatio, validateCompression, preserveRatio, preserveRecentMessages, toolResultStubThreshold} |
the options of summarize() / anchored() |
JanitorSnapshot.anchorDoc |
JanitorSnapshot.strategy (opaque strategy state; pre-4.2 snapshots still restore their anchor) |
Everything ContextChef keeps outside the window is the same thing at different addresses: durable facts, the model's working notes, tool output that was too large to keep inline, and spans of conversation that were compacted away. Since 4.2 they share one substrate — a StorageBackend that moves bytes, wrapped in a Store that adds namespaces, context:// addressing, an access index and eviction.
| Namespace | Holds | In the window? | Writable by the model |
|---|---|---|---|
memory/ |
Durable facts worth carrying between sessions | Injected into every compile | Yes (default) |
notes/ |
The model's own scratchpad | Never — that is the point | Yes (default) |
vfs/ |
Offloaded tool output, addressed by the URI left in its place | Only what it reads back | No (read-only by default) |
archive/ |
Reserved for pre-overflow spans behind a summary's citation — nothing writes here in 4.x, where overflow.archive still lands in vfs/ |
Only what it reads back | No (read-only by default) |
import { ContextChef, FileSystemBackend, InMemoryBackend, Store } from "@context-chef/core";
const chef = new ContextChef({
store: new FileSystemBackend(".context_store"), // one backend for every namespace
memory: {},
vfs: { threshold: 5_000 },
});
await chef.getStore().namespace("notes").put("plan.md", "# Plan\n");
Store.uri("notes", "plan.md"); // 'context://notes/plan.md'
Store.parseUri("context://notes/plan.md"); // { ns: 'notes', path: 'plan.md' }ChefConfig.store fills in what nothing else specifies: an explicit memory.store still wins for memory, and an explicit vfs.store / vfs.adapter / vfs.storageDir still wins for the VFS. With neither set, the 4.1 defaults apply unchanged.
Built-in backends are InMemoryBackend (process lifetime; the default when nothing is configured) and FileSystemBackend (one root directory, per-namespace layout, atomic writes). A backend is four required methods — read / write / delete / list — plus optional readAll, exists, append, search, snapshot, restore and getPhysicalPath, each queried as a capability: asking a namespace for something its backend cannot do throws StoreCapabilityError (or, from the context tool, comes back as an error the model can read) instead of failing silently.
// Pass a pre-built Store when you want per-namespace eviction or URI schemes
const store = new Store(new InMemoryBackend(), {
eviction: { vfs: { maxFiles: 200, maxBytes: 50 * 1024 * 1024 } },
});
const chef = new ContextChef({ store, memory: {} });
const notes = chef.getStore().namespace("notes");
await notes.put("plan.md", "# Plan\n", { description: "current plan" });
await notes.append("plan.md", "- ship 4.2\n");
notes.uri("plan.md"); // 'context://notes/plan.md'
if (notes.supports("search")) await notes.search("ship");
const { path } = await chef.getStore().namespace("vfs").put("big output"); // content-addressed idEvery NamespaceView method mirrors its backend's sync-or-async nature, so a synchronous backend keeps synchronous call sites (chef.offload, memory.snapshot) working; getSync / putSync / deleteSync / listSync are the explicit sync variants, and they throw a directed error rather than silently degrade when the backend answers with a Promise.
The four old storage interfaces still work. They are wrapped, not reimplemented, and every existing test of them stays green — but they are removed in 5.0:
| Deprecated | Replacement |
|---|---|
MemoryStore (memory.store) |
StorageBackend (or a Store); wrapped via Store.fromMemoryStore, MemoryStoreEntry mapping onto StoredEntry.meta one for one |
VFSStorageAdapter (vfs.adapter) |
StorageBackend as vfs.store; wrapped via Store.fromVfsAdapter, serving vfs and archive from its flat keyspace |
VFSMemoryStore(dir) |
new Store(new FileSystemBackend(dir)) as memory.store — for a new directory. A plain backend does not read VFSMemoryStore's <base64url>.mem files; to keep an existing one, keep the store or wrap it with Store.fromMemoryStore |
FileSystemAdapter(dir) |
FileSystemBackend(dir), which serves every namespace from one root |
InMemoryStore is not deprecated — it is still the simplest ephemeral memory.store for tests — but a single InMemoryBackend now covers every namespace at once.
Persistent key-value memory that survives across sessions, injected into every compile. Nothing about its semantics changed in 4.2; it simply reads and writes context://memory/<key> now.
import { ContextChef, FileSystemBackend } from "@context-chef/core";
const chef = new ContextChef({
store: new FileSystemBackend(".context_store"),
memory: {
defaultTTL: 20, // bare number = turns; { ms } / { turns } also accepted
allowedKeys: ["persona", "project_rules"],
selector: (entries) => entries.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 10),
onMemoryUpdate: (key, value, oldValue) => key !== "locked", // veto: false blocks the write
onMemoryChanged: (event) => audit.log(event),
onMemoryExpired: (entry) => audit.log(entry),
memoryPlacement: "before_history_tail",
},
});
// Direct read/write (developer use, bypasses the validation hooks)
await chef.getMemory().set("persona", "You are a senior engineer", {
description: "The agent's persona and role",
});
const value = await chef.getMemory().get("persona");allowedKeys restricts what the model may create, selector filters/sorts/truncates what gets injected (after expired entries are swept), TTL expiry fires memory:expired during compile(), and the whole namespace round-trips through chef.snapshot() / chef.restore(). On compile(), existing entries are injected as a <memory> block and — under the default tools: 'legacy' — the create_memory / modify_memory tools are added to payload.tools.
Under tools: 'legacy' you dispatch those two yourself, exactly as in 4.1:
for (const toolCall of response.tool_calls) {
if (toolCall.function.name === "create_memory") {
const { key, value, description } = JSON.parse(toolCall.function.arguments);
await chef.getMemory().createMemory(key, value, description);
} else if (toolCall.function.name === "modify_memory") {
const { action, key, value, description } = JSON.parse(toolCall.function.arguments);
if (action === "update") await chef.getMemory().updateMemory(key, value, description);
else await chef.getMemory().deleteMemory(key);
}
}Both definitions are static since 4.1 — same schema, same object references on every compile, regardless of what keys exist. (Previously modify_memory embedded an enum of live keys and appeared only when keys existed; tools sit at the top of every provider's prompt prefix, so each key mutation invalidated the entire prompt cache.) Current keys are surfaced through the injected memory block instead, and unknown-key dispatches still fail safely at updateMemory / deleteMemory. chef.handleTool dispatches both for you if you would rather not write the branch — see the context tool.
Controls where the volatile <memory> data block lands in the compiled payload. Defaults to 'after_system' (backward compatible). For applications using Anthropic prompt caching with cache breakpoints on history, switch to 'before_history_tail' so memory mutations don't invalidate the history cache.
| Placement | Top of sandwich | Last user message | When to use |
|---|---|---|---|
'after_system' (default) |
INSTRUCTION + <memory> data, combined into one role: 'system' message |
untouched | Simple agents; you don't rely on cache breakpoints past the system parameter |
'before_history_tail' |
INSTRUCTION only (stable, cacheable) | appends the <memory> data block to the original user content |
You want cache breakpoints on history (or earlier system blocks) to survive memory mutations on every turn |
The split keeps the stable usage instruction at the top of the sandwich where it caches cleanly, and ships the volatile data block at the tail of the conversation. Anthropic / Gemini adapters extract every role: 'system' message into the top-level system parameter — under 'before_history_tail' the data block stays in messages instead, so any cache breakpoint earlier in the message stream no longer hashes the changing memory text.
When dynamic state is also injected at the tail (dynamicStatePlacement: 'last_user'), the order inside the last user message is: original content → <memory> → <dynamic_state> → <implicit_context> → anchor line. When dynamic state goes to its own system message (dynamicStatePlacement: 'system'), memory still injects at the user tail with no anchor.
Volatile content sitting inside the cached prompt prefix silently invalidates prompt caching on every change. The audit checks a compiled Anthropic payload for exactly that — memory data, dynamic state, implicit context, or guardrail instructions placed at or before the last cache_control breakpoint — and names the fix per issue:
import { auditAnthropicCachePlacement } from "@context-chef/core";
const payload = await chef.compile({ target: "anthropic" });
for (const issue of auditAnthropicCachePlacement(payload)) {
console.warn(`${issue.location}: ${issue.message}`);
}
// Or let the chef warn automatically (each distinct issue once per instance):
const chef = new ContextChef({ cacheAudit: true /* Anthropic targets only */ });It recognizes the memory block by its header in both vocabularies, so the audit keeps working under tools: 'unified'.
Anthropic-only by design: it is the one provider with explicit, client-visible breakpoints, so the check is fully deterministic — a structural property of the payload, zero heuristics. OpenAI's automatic prefix cache and Gemini's implicit cache expose no marks to audit against, so no equivalent is offered for them.
// Offload if content exceeds threshold; preserves last 2000 chars by default
const safeLog = chef.offload(rawTerminalOutput);
history.push({ role: "tool", content: safeLog, tool_call_id: "call_123" });
// safeLog: original content if small, or truncated with a context://vfs/ URI
// Preserve head (first 500 chars) + tail (last 1000 chars), snapped to line boundaries
const safeOutput = chef.offload(content, { headChars: 500, tailChars: 1000 });
// No preview content — just truncation notice + URI
const safeDoc = chef.offload(largeFileContent, { headChars: 0, tailChars: 0 });
// Override threshold per call
const safeOutput2 = chef.offload(content, { threshold: 2000, tailChars: 500 });The truncation marker the model sees names the URI it can read the full content back from. Reading it back is the retrieval axis: chef.resolveRecall(uri), the recall_context tool under the legacy tool set, or view on the context tool under unified.
.context_vfs/ (or your store's root) grows unboundedly without intervention. Configure caps and trigger cleanup yourself — never automatic.
const chef = new ContextChef({
vfs: {
threshold: 5000,
maxAge: 24 * 60 * 60 * 1000, // ms since createdAt
maxFiles: 200, // LRU evict by accessedAt
maxBytes: 50 * 1024 * 1024, // true UTF-8 size (Buffer.byteLength)
onVFSEvicted: (entry, reason) => {
// 'maxAge' | 'maxFiles' | 'maxBytes' — errors logged and swallowed
logger.debug("evicted", entry.uri, reason);
},
},
});
// Manual sweep — call from your agent loop, on session end, or wire to compile:done.
const result = await chef.getOffloader().cleanupAsync();
// { evicted, evictedBytes, evictedByAge, evictedByCount, evictedByBytes, failed }
// Override caps for one call (Infinity disables a single cap).
await chef.getOffloader().cleanupAsync({ maxFiles: 0 }); // evict all over-age + allAfter a process restart, reconcile() walks the store and adopts orphan files into the in-memory index so subsequent cleanup() can see them:
const adopted = await chef.getOffloader().reconcileAsync({ measureBytes: true });
// createdAt parsed from legacy vfs_<ts>_<hash>.txt names; content-addressed names date from adoption. bytes measured if requested.Cleanup is mechanism, not policy — it is never triggered by compile(). Wire it to compile:done for per-turn enforcement, or call it on a timer / on session end. The LRU index and the caps themselves moved onto the Store in 4.2 (eviction: { vfs: { … } }); the vfs.maxAge / maxFiles / maxBytes fields set that policy and are passed on every sweep, so they keep working even against a Store you built yourself. A backend without list() / delete() cannot be swept: cleanup() throws VFSCleanupNotSupportedError (both built-in backends implement them).
Production patterns — see
docs/vfs-lifecycle-recipes.mdfor runnable recipes covering long-running servers, serverless cold-startreconcile(), AI SDK middleware integration, custom storage adapters (Redis example), and choosing your eviction strategy.
With overflow.archive (or VFS offloading) enabled, the full span behind a summary stays retrievable by URI:
import { getRecallToolDefinition } from "@context-chef/core";
chef.registerTools([getRecallToolDefinition()]);
// In your agent loop:
if (call.function.name === "recall_context") {
const { uri } = JSON.parse(call.function.arguments);
const content = await chef.resolveRecall(uri); // full stored content, or null
history.push({ role: "tool", tool_call_id: call.id, content: content ?? "[not found]" });
}resolveRecall(uri, { format: 'text' }) renders a stored message span as readable text instead of raw JSON. Under tools: 'unified' this is view on the context tool and chef.handleTool does the dispatch — getRecallToolDefinition() remains for legacy setups.
ChefConfig.tools picks which library-owned tools compile() emits:
const chef = new ContextChef({
store: new FileSystemBackend(".context_store"),
memory: {},
tools: "unified", // default: 'legacy'
contextTool: { writable: ["memory", "notes"] }, // the default policy
overflow: { handoff: { budgetTokens: 2_000 } },
});'legacy'(default): the Memory module'screate_memory/modify_memory.recall_contextandnew_contextstay opt-in — register them yourself.'unified': onecontexttool covering every namespace, plusnew_contextwhenoverflow.handoffis configured. The legacy trio is not emitted; the two sets never co-exist in one payload.
The default cannot flip in a minor release. Tool names are dispatch keys in your agent loop — a if (call.function.name === 'create_memory') branch stops matching the moment the payload starts saying context. 'unified' becomes the default in 5.0.
The tool is memory_20250818-shaped: one static, frozen, reference-stable definition with exactly one enum (command), so no live key list ever enters the cached prefix. Seven commands, addressed by context://<ns>/<path> (the prefix is optional, so notes/plan.md works too):
| Command | Does | Notes |
|---|---|---|
view |
Reads an entry, or lists a namespace/directory | Line-numbered for notes/; vfs/ and archive/ render through the recall path |
create |
Writes a new entry | Fails when one already exists |
str_replace |
Swaps the single exact occurrence of old_str |
Ambiguous or missing match is an error the model reads |
insert |
Puts insert_text at 0-based insert_line |
|
delete |
Removes an entry | |
rename |
Moves an entry to new_path in the same namespace |
Implemented as create + delete |
search |
Finds entries under path matching query |
Falls back to list + get when the backend has no native search |
memory/ operations route through the Memory module, so allowedKeys, the onMemoryUpdate veto, onMemoryChanged, TTL and the update counter all apply exactly as they do for the legacy tools. notes/ goes straight to the store. vfs/ and archive/ are view-only by default.
for (const call of response.tool_calls) {
if (chef.ownsTool(call.function.name)) {
const content = await chef.handleTool({
name: call.function.name,
arguments: call.function.arguments, // JSON string or already-parsed object
});
history.push({ role: "tool", tool_call_id: call.id, content });
continue;
}
await executeYourOwnTool(call);
}ownsTool covers context, new_context and the legacy create_memory / modify_memory / recall_context, independently of tools mode — the mode decides what compile() emits, not what the dispatcher understands, so a migration where the model occasionally reaches for an old name still works. Model-facing mistakes never throw: an unknown path, a missing argument, a write to a read-only namespace and a veto from onMemoryUpdate all come back as Error: … text the model can read and correct. Only a tool name this chef does not own throws — that is a routing bug, so guard with ownsTool.
Reading is never restricted: everything in the store is this conversation's own overflow, and a model that can be handed a context:// URI can be handed what is behind it. Writing is:
new ContextChef({ contextTool: { writable: ["notes"] } }); // memory read-only
new ContextChef({ contextTool: { writable: ["memory", "notes", "vfs"] } }); // let it edit offloaded outputThe policy is enforced in the dispatcher, not in the store — the store moves bytes, and your own code writes to any namespace freely.
tools also picks the words the model reads. Under 'unified' the memory instruction and memory block, the offload truncation marker, the summary wrapper (which then carries the Context window: … (previous: …) lineage line) and the default handoff notice are all rewritten in context:// addressing and name the context tool — so the prompt never mentions a tool the payload does not carry. Under 'legacy' every one of those strings is byte-identical to 4.1. The two vocabularies are LEGACY_VOCABULARY / UNIFIED_VOCABULARY, resolved once per chef; a standalone Memory / Offloader / Janitor built without a chef keeps the 4.x wording.
chef.registerTools([
{ name: "read_file", description: "Read a file", tags: ["file", "read"] },
{ name: "run_bash", description: "Run a command", tags: ["shell"] },
{
name: "get_time",
description: "Get timestamp" /* no tags = always kept */,
},
]);
const { tools, removed } = chef.getPruner().pruneByTask("Read the auth.ts file");
// tools: [read_file, get_time]Also supports allowOnly(names) and pruneByTaskAndAllowlist(task, names).
Block specific tools at dispatch time without breaking KV cache. Useful for permission control, environment safety, sandboxing, rate limits, and feature flags. The compiled tools array stays unchanged — enforcement happens via checkToolCall in your agent loop.
// Set policy (rare event — startup, on user role change, prod env, etc.)
chef.getPruner().setBlockedTools(["delete_file", "tail_logs"]);
// In your agent loop, gate every tool call before dispatch:
for (const call of response.tool_calls) {
const check = chef.checkToolCall({ name: call.function.name });
if (!check.allowed) {
history.push({
role: "tool",
tool_call_id: call.id,
content: check.reason, // e.g. 'Tool "delete_file" is currently blocked.'
});
continue;
}
await executeTool(call);
}checkToolCall returns a discriminated union (ToolCallCheckResult), so TypeScript guarantees reason is present iff the call is rejected. KV cache is preserved across blocklist changes — the LLM continues to see the full tool set; the gate is dispatch-side only.
Layer 1 — Namespaces: Core tools grouped into stable tool definitions. The tool list never changes across turns.
Layer 2 — Lazy Loading: Long-tail tools registered as a lightweight XML directory. The LLM loads full schemas on demand via load_toolkit.
// Layer 1: Stable namespace tools
chef.registerNamespaces([
{
name: "file_ops",
description: "File system operations",
tools: [
{
name: "read_file",
description: "Read a file",
parameters: { path: { type: "string" } },
},
{
name: "write_file",
description: "Write to a file",
parameters: { path: { type: "string" }, content: { type: "string" } },
},
],
},
{
name: "terminal",
description: "Shell command execution",
tools: [
{
name: "run_bash",
description: "Execute a command",
parameters: { command: { type: "string" } },
},
],
},
]);
// Layer 2: On-demand toolkits
chef.registerToolkits([
{
name: "Weather",
description: "Weather forecast APIs",
tools: [
/* ... */
],
},
{
name: "Database",
description: "SQL query and schema inspection",
tools: [
/* ... */
],
},
]);
// Compile — tools: [file_ops, terminal, load_toolkit] (always stable)
const { tools, directoryXml } = chef.getPruner().compile();
// directoryXml: inject into system prompt so LLM knows available toolkitsAgent Loop integration:
for (const toolCall of response.tool_calls) {
if (chef.getPruner().isNamespaceCall(toolCall)) {
// Route namespace call to real tool
const { toolName, args } = chef.getPruner().resolveNamespace(toolCall);
const result = await executeTool(toolName, args);
} else if (chef.getPruner().isToolkitLoader(toolCall)) {
// LLM requested a toolkit — expand and re-call
const parsed = JSON.parse(toolCall.function.arguments);
const newTools = chef.getPruner().extractToolkit(parsed.toolkit_name);
// Merge newTools into the next LLM request
}
}One level is enough. The two-layer namespace → tools depth is the empirically optimal hierarchy for tool selection — deeper nesting hurts accuracy without saving meaningful context (arXiv:2607.17598). Don't nest namespaces inside namespaces.
Set deferLoading: true on a ToolDefinition to annotate it for Anthropic's server-side Tool Search: the flag passes through payload.tools verbatim on the Anthropic target, letting the API surface the tool's full schema on demand instead of loading it up front. It is an annotation only — other targets ignore it.
A Skill is a portable bundle of (name + description + instructions + ...) that scopes the agent's behavior for a specific phase or domain. Activating a skill injects its instructions as a dedicated system message between your system prompt and the memory block — no prompt rewriting on your side. Skills can be inline JS objects or loaded from SKILL.md files (same frontmatter shape as Claude Code / Mastra / OpenCode).
import { ContextChef, type Skill } from "@context-chef/core";
const planning: Skill = {
name: "planning",
description: "Plan changes before editing",
whenToUse: "When the task is non-trivial and requires multiple steps",
instructions: "Read code, list affected files, write plan to scratchpad.",
allowedTools: ["read_file", "grep"], // annotation only — chef does NOT enforce
};
const chef = new ContextChef();
chef.registerSkills([planning]);
chef.activateSkill("planning");
// activateSkill also accepts a Skill object directly, or null to deactivate.
const { messages, meta } = await chef.compile({ target: "openai" });
// messages = [...systemPrompt, { role: 'system', content: planning.instructions }, ...rest]
// meta.activeSkillName === 'planning'Where the active skill's instructions are delivered. The default 'after_system' is the behavior above, bit-for-bit: a dedicated role: 'system' message right after your system prompt. Those tokens live in the cacheable prefix — free to re-send — but every activation, switch, or deactivation rewrites that prefix and costs one full cache invalidation.
Under 'tail' the instructions leave the prefix entirely and lead the tail stitch, wrapped in <skill_instructions skill="NAME">:
const chef = new ContextChef({ skillPlacement: "tail" });
chef.registerSkills([planning, editing]); // two Skill objects, shaped like the one above
chef.setSystemPrompt([{ role: "system", content: basePrompt }]).setHistory(history);
chef.activateSkill("planning");
const a = await chef.compile({ target: "anthropic" });
chef.activateSkill("editing");
const b = await chef.compile({ target: "anthropic" });
// `a` and `b` are byte-identical up to the last user message — activating,
// switching, and deactivating never touch the cached prefix. Only the tail moved:
// <skill_instructions skill="editing">…</skill_instructions>The trade-off is real in both directions: 'tail' re-sends the full instruction text uncached on every request; 'after_system' re-sends nothing but pays a cache miss on every switch. Pick 'tail' when the agent switches modes often relative to how long each mode stays active, 'after_system' for a mode that is set once and lives for the session.
Placement changes delivery only — meta.activeSkillName, getActiveSkill(), and snapshot/restore behave identically. In the tail, the skill block leads the fixed stitch order (<skill_instructions> → <memory> → <dynamic_state> → <implicit_context> → <announcements> → anchor) so the model reads "who you are right now" before the state it applies to, and it does not trigger the anchor line on its own — it is self-describing inside its own tag. If cacheAudit catches a <skill_instructions block at or before your last cache_control breakpoint, the breakpoint is too late, not the placement.
import {
loadSkill,
loadSkillsDir,
loadSkillsDirs,
renderSkill,
formatSkillListing,
} from "@context-chef/core";
// Load a single skill file
const skill = await loadSkill("./skills/db-debug/SKILL.md");
// Scan a directory: each subdir/SKILL.md becomes a Skill. Tolerant — bad
// files surface in `errors`, and `warnings` (4.1) reports mechanical quality
// findings (thin descriptions, oversized instructions, malformed
// allowedTools, dangling relative resource links) without blocking the load.
const { skills, errors, warnings } = await loadSkillsDir("./skills");
for (const w of warnings) console.warn(`${w.path}: ${w.message}`);
chef.registerSkills(skills);
// Or merge several sources at once (e.g. builtin + user + project) — later dirs
// win on name collisions, dirs are realpath-deduped, and an optional `namespace`
// callback can prefix names per source:
const merged = await loadSkillsDirs([builtinDir, userDir, projectDir], {
precedence: "last-wins",
});
// Render a system-prompt-friendly listing (useful for LLM-driven `load_skill` tool)
const listing = formatSkillListing(skills, { format: "plain" });SKILL.md parsing is tolerant: block scalars (> folded / | literal), - item lists, and kebab-case keys (allowed-tools, when-to-use) all load. Any frontmatter key chef doesn't recognize is preserved verbatim on skill.metadata for your host to read — chef never interprets it (the same annotation-only stance as allowedTools). A known field written in a malformed nested shape throws, so a typo'd allowed-tools surfaces instead of silently disabling restrictions.
The listing lets the LLM pick a skill itself via a load_skill tool. Keep the tool definition static — the listing goes in your system prompt, not the tool description, and skill_name is a plain string, not an enum of registered names. Tool schemas sit at the very top of every provider's prompt prefix, so a listing-bearing description or a live-name enum rewrites the schema whenever the skill set changes and invalidates the entire prompt cache (the same reasoning as the static memory tool schemas, 4.1). Unknown names are caught at dispatch time instead — activateSkill throws with the available names:
const loadSkillTool = {
name: "load_skill",
description:
"Load a skill to specialize for the current task. " +
"The available skills are listed in the system prompt.",
parameters: {
skill_name: { type: "string", description: "A skill name from the listing." },
},
};
// The listing lives in the (stable) system prompt:
chef.setSystemPrompt([
{ role: "system", content: `${basePrompt}\n\nAvailable skills:\n${listing}` },
]);
// In your dispatch loop — dispatch-gate, like the memory tools:
if (call.name === "load_skill") {
try {
chef.activateSkill(call.args.skill_name);
/* push success tool result, continue loop */
} catch (err) {
/* push String(err) as the tool result — the model self-corrects */
}
}renderSkill substitutes $ARGUMENTS / $0..$N / $name / ${VAR} placeholders in a skill's instructions and returns a new Skill (pure — no I/O). Where the rendered skill is delivered is your call: the system slot above (a persistent "mode"), or a host-appended message for progressive disclosure of many skills at once.
const triage = await loadSkill("./skills/triage/SKILL.md");
// Fill $ARGUMENTS / $0.. / ${SKILL_DIR} before activating:
chef.activateSkill(renderSkill(triage, { args: "p0 incidents" }));Referenced files (@./schema.json, …) are not inlined by chef — pass renderSkill(skill, { includeBaseDir: true }) and let your agent read them on demand via its own file tool.
For the design rationale (Skill ⊥ Pruner decoupling, SKILL.md frontmatter shape, mode-wiring recipes, LLM-driven skill loading, reference files) see SKILL_SPEC.md. For argument rendering, multi-source loading, and the two delivery models, see docs/skill-interop-design.md and the usage recipes in docs/skill-recipes.md.
Capabilities change mid-session: a permission is granted, a toolkit is loaded, a rate limit withdraws web_search. The payload changes shape, but nothing tells the model what changed — it keeps calling the tool that vanished, or ignores the one that just appeared. announce() states the change and keeps stating it until you retract it.
chef.announce("tools:added", "Tools newly available: read_file, grep");
chef.announce(
"tools:removed",
"web_search has been withdrawn; calls to it will be rejected",
);
chef.getAnnouncements();
// [{ id: 'tools:added', content: '…', channel: 'auto' }, { id: 'tools:removed', … }]
chef.retractAnnouncement("tools:added"); // → true; the next compile carries no trace of itEvery standing announcement renders into one block, in insertion order:
<announcements>
<announcement id="tools:added">
Tools newly available: read_file, grep
</announcement>
<announcement id="tools:removed">
web_search has been withdrawn; calls to it will be rejected
</announcement>
</announcements>State, not event. An announcement is a statement of the current capability set, re-rendered into every compile() — not a one-shot message appended to history. That is the point: chef's injections never persist into your history, so a retracted announcement disappears completely from subsequent payloads instead of lingering as an obsolete turn the model keeps re-reading. announce() upserts by id — re-announcing the same id replaces content and channel while keeping the original insertion position, so the block stays stable as it is updated. retractAnnouncement(id) returns whether anything was removed.
| Channel | Where it renders | Notes |
|---|---|---|
'user_tail' |
joins the tail stitch of the last user message — after <dynamic_state> / <implicit_context>, before the anchor line |
Works on every provider. Unlike skill instructions and memory data, announcements do trigger the "Above is the current system state" anchor — they are system state |
'system' |
one combined _positional system message placed right after the conversational tail (and still in front of any assistant prefill) |
Operator precedence, and the cached prefix stays intact |
'auto' (default) |
routes by target: 'system' on the Anthropic target, 'user_tail' everywhere else |
See the caveat below |
Channels are per-announcement, so a mixed set splits across both delivery paths within a single compile. The handoff notice rides this same channel resolution — rendered last, for that one compile only, and never added to the standing set.
The auto-routing caveat. 'auto' routes by target, not by model — chef never sees your model id. Mid-conversation role: "system" messages are native on Fable 5 / Mythos 5 / Opus 4.8 / Opus 5, but not on Sonnet 5. If you compile for the Anthropic target and call Sonnet 5, pass the channel explicitly:
chef.announce("tools:added", "Tools newly available: grep", { channel: "user_tail" });Mechanism, not policy — chef will not guess your model for you.
The 'system' channel rides Message._positional: a role: 'system' message flagged positional stays at its position in the stream instead of being hoisted into the provider's top-level system parameter. Per adapter: Anthropic emits a mid-conversation system message (falling back to hoisting, with a one-time warning, if it would precede every user/tool message); OpenAI Chat Completions keeps system messages inline anyway, so the flag is a no-op; the Responses adapter emits an inline message item instead of folding the text into instructions; Gemini has no system role in contents and degrades it to a user entry verbatim.
Wording matters. State facts, do not command. "Tools newly available: read_file, grep" and "web_search has been withdrawn; calls to it will be rejected" read as system state. "You must now use read_file" reads as an instruction competing with your system prompt — and the model will weigh it against everything else you told it.
Lifecycle. Announcements survive clearHistory() — they describe the current capability set, which a fresh conversation still needs; retract them explicitly when the change no longer holds. They ride ChefSnapshot and round-trip through snapshot() / restore(); restoring a snapshot taken before 4.1 (no announcements field) yields an empty set rather than leaving the previous ones live. With cacheAudit: true, an <announcements> block caught at or before your last cache_control breakpoint is flagged — announcements always render at the conversational tail, so move the breakpoint earlier.
There is no automatic tool-delta detection. Chef renders what you announce; deciding what changed is policy and stays in your loop:
let previousTools = new Set<string>();
function syncToolAnnouncements(next: string[]) {
const added = next.filter((name) => !previousTools.has(name));
const removed = [...previousTools].filter((name) => !next.includes(name));
if (added.length) {
chef.announce("tools:added", `Tools newly available: ${added.join(", ")}`);
} else {
chef.retractAnnouncement("tools:added"); // no longer new — stop repeating it
}
if (removed.length) {
chef.announce(
"tools:removed",
`Withdrawn; calls to these will be rejected: ${removed.join(", ")}`,
);
} else {
chef.retractAnnouncement("tools:removed");
}
previousTools = new Set(next);
}
// Pairs with the Pruner blocklist — the gate rejects, the announcement explains:
chef.getPruner().setBlockedTools(["delete_file"]);
chef.announce("tools:blocked", "delete_file is disabled in this environment");Call it wherever your tool list is decided — right before you build the request, or from a compile:done handler if the Pruner owns the list (payload.tools), remembering that an announcement made there lands on the next compile, not the one that just finished.
Related: ToolDefinition.deferLoading annotates a tool as withheld from the initial context (Anthropic tool search, and the mid-conversation-tool-changes beta's tool_addition mechanism); deferred definitions are stripped before the cache key is computed, so adding them never invalidates an existing cache entry. Announcements are the text-side counterpart — chef's IR is text-content based, so tool_addition / tool_removal content blocks are not passed through.
Capture and rollback full context state for branching or error recovery.
const snap = chef.snapshot("before risky tool call");
// ... agent executes tool, something goes wrong ...
chef.restore(snap); // rolls back everything: history, dynamic state, janitor state, memorycompile() is an ordered list of named phases — start → transform-tool-results → handoff → overflow → inject → memory → skill → assemble → tail → adapt → audit → done. Slots are the composition points fired at those boundaries. Unlike events, a slot handler participates in the compile: it can veto overflow, inject context, or rewrite the assembled messages.
chef
.use("before-assemble", async (ctx) => ctx.inject(await vectorDB.search(ctx.dynamicStateXml)))
.use("after-adapt", (payload) => metrics.record(payload));
chef.unuse("after-adapt", handler); // removes one registration| Slot | Signature | Contract |
|---|---|---|
before-overflow |
({ history, budget }) => void | false |
Return false to skip the overflow phase for this compile. budget is the runner's real reading: { limit, current, trigger, remaining } |
after-overflow |
({ history, result }) => void |
result is null when the phase was skipped; otherwise OverflowResult with meta.strategy / meta.changed / meta.windowId |
before-assemble |
(ctx) => void |
ctx is the BeforeCompileContext plus inject(text); injected blocks accumulate in registration order into <implicit_context> |
after-assemble |
(messages) => Message[] |
Handlers chain — each receives the previous one's result |
before-adapt |
(messages) => void |
Read-only view of the final message array, before the target adapter runs |
after-adapt |
(payload) => void |
Read-only view of the compiled payload, before compile:done |
Handlers run in registration order and are awaited one after another. The legacy config hooks register on this same registry at construction, so they always run first — there is no second code path:
| Deprecated field | Registers as |
|---|---|
ChefConfig.onBeforeCompile |
before-assemble (the returned string becomes ctx.inject(...)) |
ChefConfig.transformContext |
after-assemble |
Both keep working unchanged and are removed in 5.0. ChefConfig.transformToolResult is not deprecated: it is a per-message transform in the transform-tool-results phase (applied to every role: 'tool' message before compression), not a slot. JanitorConfig.onBeforeCompress is not deprecated either: it is a runner callback that fires inside the Janitor once the budget verdict says overflow will run, may return a replacement history, and works on a standalone Janitor with no slot registry.
Slot errors are not isolated — unlike event handlers, a throwing slot handler fails the compile, exactly like the config hooks it generalizes. Wrap your own logic in try/catch when a failure should be survivable.
// The 4.1 hooks, written as slots
chef.use("before-overflow", ({ history, budget }) => {
if (history.length < 4) return false; // never compress a short conversation
console.log(`over budget by ${-budget.remaining} tokens`);
});
chef.use("after-overflow", ({ result }) => {
if (result?.meta.changed) store.recordWindowBoundary(result.meta.windowId);
});ChefConfig.pipelineChecks: true verifies, once after the whole after-assemble chain, that pinned messages survived and tool call/result pairs are still paired — and once after the tail phase, that nothing ahead of the tail insertion point changed. The assemble check compares the list the chain started from against the list it produced, so a violation names the chain rather than an individual handler.
const chef = new ContextChef({ pipelineChecks: true });
chef.on("pipeline:invariant", ({ phase, message }) => console.warn(`[${phase}] ${message}`));Violations are reported, never enforced: each goes to ChefConfig.logger (or console) and to the pipeline:invariant event, and compile() never throws because of a check. It costs a snapshot plus a serialization pass per compile, so keep it off in production. Default false.
Unified event system for observability across all internal modules. Subscribe via chef.on(), unsubscribe via chef.off().
// Log when history gets compressed
chef.on('compress', ({ summary, truncatedCount }) => {
console.log(`Compressed ${truncatedCount} messages`);
});
// Track compile metrics
chef.on('compile:done', ({ payload }) => {
metrics.track('compile', { messageCount: payload.messages.length });
});
// Monitor memory changes
chef.on('memory:changed', ({ type, key, value }) => {
console.log(`Memory ${type}: ${key}`);
});| Event | Payload | Description |
|---|---|---|
compile:start |
{ systemPrompt, history } |
Emitted at the start of compile() |
compile:done |
{ payload } |
Emitted after compile() produces the final payload |
compress:start |
{ historyLength, currentTokens, limit } |
Budget exceeded — compression is about to run (before summarization) |
compress:end |
{ compressed } |
Compression phase finished. compressed: false = budget was fine or the result was rejected |
compress |
{ summary, truncatedCount, details } |
Emitted after Janitor compresses history |
offload:created |
{ uri } |
Content was offloaded to the VFS (via chef.offload / offloadAsync or the compression archive) |
pruner:tool-blocked |
{ name } |
checkToolCall() rejected a tool call against the Pruner blocklist |
pipeline:invariant |
{ phase, message } |
A pipelineChecks invariant was violated — a pinned message dropped, a tool pair split, or content rewritten ahead of the tail. Reported only |
memory:changed |
{ type, key, value, oldValue } |
Emitted after any memory mutation (set, delete, expire) |
memory:expired |
MemoryEntry |
Emitted when a memory entry expires during compile() |
Events are observation-only — they don't affect control flow. Anything that participates in the compile is a slot (chef.use(...), plus the onBeforeCompile / transformContext aliases); onBeforeCompress and onMemoryUpdate remain config callbacks on their modules.
Handler error isolation (v4). A throwing or rejecting event handler is logged and the remaining handlers still run — pre-4.0, a throwing handler failed the whole compile().
Events coexist with existing config callbacks: if you provide onCompress in JanitorConfig, it fires first, then the compress event is emitted.
Pass an AbortSignal to compile() to cancel an in-flight compile and propagate the signal to all event handlers fired during that call.
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000); // hard 5s budget
chef.on('compile:done', async ({ payload }, signal) => {
// signal === controller.signal — forward it to slow async work
await db.write(payload, { signal });
await metrics.report(payload, { signal });
});
try {
await chef.compile({ target: 'openai', signal: controller.signal });
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
// compile was cancelled mid-flight (overflow / inject / memory / assemble boundary)
}
throw err;
}Two effects:
- Forwarded to handlers —
chef.on(event, (payload, signal?) => ...)receives the signal as the second argument. Handler can pass it tofetch, DB clients, or any cooperative API. - Checked at compile() boundaries — after the
overflow,inject,memoryandassemblephases (the same points as before 4.2: Janitor compression,before-assemble/onBeforeCompile, memory,after-assemble/transformContext). Aborts throw viasignal.throwIfAborted().
compile:start fires before the first abort check, so observers may receive a compile:start for a compile that ultimately throws without firing compile:done. Memory events fired from external memory().set() / delete() calls (outside compile()) get signal: undefined.
Canonical pattern: one ContextChef instance per concurrent caller. A chef holds mutable state across await points (in-flight signal, memory turn counter, active skill, history reference). Per-request instantiation gives each call its own state — no shared mutable state means no race.
// Express / Fastify / Hono — one chef per request
app.post('/agent', async (req, res) => {
const chef = new ContextChef({ memory: { store: sharedMemoryStore } });
chef.setHistory(req.body.history);
const payload = await chef.compile({ target: 'openai' });
res.json(payload);
});If memory needs to span requests, lift the store out (a shared FileSystemBackend, your own Redis-backed StorageBackend) and pass it as store to per-request chefs — store-level concurrency is the store's responsibility, not the chef's.
Sharing one chef across concurrent compile() calls is single-threaded by design. Two compile() calls on the same instance clobber each other's _currentSignal, double-advance the memory turn counter, and interleave skill/history reads. Serialize per instance (await chef.compile() chained), or use the per-request pattern above. A snapshot+serialize defensive option is in the roadmap (TODO T2.4.1, low priority) but is not needed for canonical usage.
Convert OpenAI / Anthropic / Gemini native messages to ContextChef IR, automatically separating system and history. Each adapter sanitizes the result via ensureValidHistory at the boundary — orphan tool results are dropped, missing tool results get an [No tool result available] placeholder, and the first non-system message is forced to be a user message. IR you build manually with chef.setHistory(...) is NOT sanitized; trust the IR or call ensureValidHistory(messages) yourself.
import { fromOpenAI, fromAnthropic, fromGemini } from "@context-chef/core";
// OpenAI
const { system, history } = fromOpenAI(openaiMessages);
chef.setSystemPrompt(system).setHistory(history);
// Anthropic (system is a separate top-level parameter)
const { system, history } = fromAnthropic(anthropicMessages, anthropicSystem);
chef.setSystemPrompt(system).setHistory(history);
// Gemini (systemInstruction is a separate top-level parameter)
const { system, history } = fromGemini(geminiContents, systemInstruction);
chef.setSystemPrompt(system).setHistory(history);Multimodal content (images, files) is automatically converted to IR attachments:
| Provider Format | IR Field |
|---|---|
OpenAI image_url / file |
attachments: [{ mediaType, data }] |
Anthropic image / document |
attachments: [{ mediaType, data }] |
Gemini inlineData / fileData |
attachments: [{ mediaType, data }] |
compile() converts attachments back to the corresponding provider format. During compression, Janitor guides the compression model to describe image content.
| Feature | OpenAI | Anthropic | Gemini |
|---|---|---|---|
| Format | Chat Completions | Messages API | generateContent |
| Cache breakpoints | Stripped | cache_control: { type: 'ephemeral' } |
Stripped (uses separate CachedContent API) |
| Prefill (trailing assistant) | Degraded to [System Note] |
Native support | Degraded to [System Note] |
thinking field |
Stripped | Mapped to ThinkingBlockParam |
Stripped |
| Tool calls | tool_calls array |
tool_use blocks |
functionCall parts |
attachments |
image_url / file content parts |
image / document blocks |
inlineData / fileData parts |
Adapters are selected automatically by compile({ target }). You can also use them standalone:
import { getAdapter } from "@context-chef/core";
const adapter = getAdapter("gemini");
const payload = adapter.compile(messages);A fourth built-in target for the OpenAI Responses API. compile({ target: "openai-responses" }) produces an OpenAIResponsesPayload { instructions?, input, tools?, meta? }, and fromOpenAIResponses(items, instructions?) is the matching input adapter:
import { fromOpenAIResponses } from "@context-chef/core";
const payload = await chef.compile({ target: "openai-responses" });
const { system, history } = fromOpenAIResponses(response.output, instructions);The round-trip handles message / function_call / function_call_output items (joined by call_id, out-of-order safe), preserves reasoning items' encrypted_content byte-identically, and converts input_image / input_file parts to IR attachments.
Gemini 3.x rejects current-turn function calls whose thought signatures are missing (HTTP 400). fromGemini captures them — ToolCall.thoughtSignature for functionCall parts, a passthrough field for text parts — and GeminiAdapter re-emits them verbatim. They are immune to compact({ clear: ['thinking'] }).
new OpenAIAdapter({ preserveThinkingAsText: true }) / new GeminiAdapter({ preserveThinkingAsText: true }) convert Anthropic-style thinking into a <thinking>...</thinking> text prefix instead of dropping it — useful when moving a Claude conversation to another provider mid-session. redacted_thinking is never textified (dropped, with one warning). Default false.
The three built-ins ('openai' | 'anthropic' | 'gemini') are registered automatically. To plug in a third-party provider (Cohere, Mistral, an in-house protocol), implement ITargetAdapter and register it once:
import { adapterRegistry, ITargetAdapter } from "@context-chef/core";
class CohereAdapter implements ITargetAdapter {
compile(messages) {
/* return Cohere-shaped payload */
}
}
adapterRegistry.register("cohere", new CohereAdapter());
await chef.compile({ target: "cohere" }); // routed via the registrycompile({ target }) accepts three forms:
| Form | Example | Use case |
|---|---|---|
| Built-in literal | compile({ target: "openai" }) |
Strict payload type via the type overloads |
| Registered name | compile({ target: "cohere" }) |
Reuse the same custom adapter many times |
ITargetAdapter |
compile({ target: new MyAdapter() }) |
One-off use / tests — bypasses the registry |
Set defaultTarget once in the constructor to avoid repeating it on every call:
const chef = new ContextChef({ defaultTarget: "anthropic" });
await chef.compile(); // → AnthropicPayloadResolution order in compile():
options.target → ChefConfig.defaultTarget → 'openai' (final built-in fallback).
For plugin systems and test isolation, pass a sourceId so a batch of registrations can be torn down together:
adapterRegistry.register("cohere", new CohereAdapter(), "my-plugin");
adapterRegistry.register("mistral", new MistralAdapter(), "my-plugin");
// Later — unload the entire plugin in one call
adapterRegistry.unregisterBySource("my-plugin");Replacing a built-in name (e.g.
register('openai', myFork)) keeps the strict overload's payload return type —compile({ target: 'openai' })is still typedPromise<OpenAIPayload>, so your replacement must honor that shape at runtime. TypeScript can't enforce this for you.
ContextChef provides Claude Code Skills that help you integrate the library into your project interactively. Each skill analyzes your existing codebase and generates tailored integration code.
| Skill | Description |
|---|---|
context-chef-core |
Integrate @context-chef/core — full control over compilation pipeline, multi-provider support |
context-chef-middleware |
Integrate @context-chef/ai-sdk-middleware — drop-in AI SDK middleware, zero code changes |
Install only what you need:
# Core library (OpenAI / Anthropic / Gemini direct SDK usage)
npx skills add MyPrototypeWhat/context-chef --skill context-chef-core
# AI SDK middleware (Vercel AI SDK v7+)
npx skills add MyPrototypeWhat/context-chef --skill context-chef-middleware
# All
npx skills add MyPrototypeWhat/context-chefOpen Claude Code in your project and type:
/context-chef-core
# or
/context-chef-middleware
Claude will:
- Detect your setup — LLM SDK, package manager, TypeScript vs JavaScript
- Ask about your needs — history compression, tool management, truncation, memory, etc.
- Generate integration code — tailored to your project structure and existing agent loop
- Explain the architecture — processing pipeline, cache breakpoints, dynamic state placement
